Two changes so an empty metered account stops being a platform outage. 1. `build_provider` prefers the subscription token over ANTHROPIC_API_KEY. A bare model name resolves to whatever this returns, so making it the subscription means no server-side call can reach the metered key by construction — rather than by a source-grep test that already missed four call sites once. The metered key remains a fallback and now warns loudly when it is the one in use; boot no longer requires it at all. 2. `complete_with_fallback` walks a declared chain when a model has no capacity: opus -> haiku -> glm:glm-4.7 by default, overridable via CLAWMATES_MODEL_FALLBACK, empty to disable. Measured on gw-04 today: opus and sonnet return 429 on the subscription while haiku, GLM and Kimi all return 200, so a capped window no longer means "the planner is gone". The chain returns the model that ANSWERED, and every caller persists it — mission_plan_proposals.author_model, mission_team_proposals.author_model, and the swarm's step role. A plan drafted by the third link and filed as an opus plan is a silent quality change, which is the failure shape this project keeps paying for. Two negative controls hold the design: the chain never retries the model that just failed as its own fallback, and it steps down ONLY for a capacity failure — walking it on a malformed prompt would ask three models the same bad question and report the third one's confusion. Co-Authored-By: Claude Opus 5 <[email protected]>
360 lines
14 KiB
Rust
360 lines
14 KiB
Rust
//! `/api/missions/{id}/plan-proposals` — let a model author the phases.
|
|
//!
|
|
//! W1 / #13, and the sibling of [`crate::routes::mission_roster`]: that one has
|
|
//! a model size the team, this one has it decide what the work is. Same three
|
|
//! verbs and the same rule — propose and decide are separate, because only the
|
|
//! second one changes a mission.
|
|
//!
|
|
//! The model is handed two lists it may not depart from: the phase kinds
|
|
//! `phase_runner` dispatches on, and the config keys `phase_config` says have
|
|
//! readers. Both are enforced again on the way in, so a plan cannot describe
|
|
//! work this platform will accept and then not do.
|
|
|
|
use axum::extract::{Path, State};
|
|
use axum::Json;
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::{json, Value};
|
|
use uuid::Uuid;
|
|
|
|
use crate::mission_plan::{Plan, MAX_PHASES, PLANNABLE_KINDS};
|
|
use crate::{ApiError, AppState, Authed};
|
|
|
|
const PLANNER_MODEL: &str = "claude-opus-4-8";
|
|
|
|
/// What the repository actually contains, for the planner's prompt.
|
|
///
|
|
/// Names were not enough. Given the root listing alone, the planner wrote
|
|
/// "optimise the hot path" for a crate whose hot path is
|
|
/// `add(a: i64, b: i64) -> i64` — a mission that was unachievable from the
|
|
/// moment it was written, and that nothing discovered until an agent had built a
|
|
/// benchmark harness to measure an integer addition.
|
|
///
|
|
/// Read from the FORGE, not a checkout: at proposal time the mission is still a
|
|
/// draft and `ensure_checkout` has not run, so there is nothing on disk. Every
|
|
/// failure degrades to a STATED absence — a planner told "the listing could not
|
|
/// be read" can hedge; one told nothing assumes.
|
|
async fn repo_digest(pool: &sqlx::PgPool, mission_id: uuid::Uuid) -> String {
|
|
let row: Option<(Option<String>, Option<String>, Option<String>)> = sqlx::query_as(
|
|
"SELECT r.owner, r.name, r.default_branch
|
|
FROM missions m JOIN repos r ON r.id = m.repo_id
|
|
WHERE m.id = $1",
|
|
)
|
|
.bind(mission_id)
|
|
.fetch_optional(pool)
|
|
.await
|
|
.ok()
|
|
.flatten();
|
|
let Some((Some(owner), Some(name), branch)) = row else {
|
|
return "(this mission has no repository)".to_string();
|
|
};
|
|
let branch = branch.unwrap_or_else(|| "main".to_string());
|
|
let token = std::env::var("GITEA_TOKEN").unwrap_or_default();
|
|
let Ok(client) = reqwest::Client::builder()
|
|
.timeout(std::time::Duration::from_secs(20))
|
|
.build()
|
|
else {
|
|
return "(the repository could not be read)".to_string();
|
|
};
|
|
let auth = |r: reqwest::RequestBuilder| {
|
|
if token.trim().is_empty() {
|
|
r
|
|
} else {
|
|
r.header("Authorization", format!("token {token}"))
|
|
}
|
|
};
|
|
|
|
// The whole tree in one call, so "does this repo have benches/" is a fact
|
|
// rather than an inference from the root.
|
|
let tree_url = format!(
|
|
"https://git.redclaw.dev/api/v1/repos/{owner}/{name}/git/trees/{branch}?recursive=true&per_page=1000"
|
|
);
|
|
let tree: serde_json::Value = match auth(client.get(&tree_url)).send().await {
|
|
Ok(r) if r.status().is_success() => r.json().await.unwrap_or_default(),
|
|
_ => return "(the repository tree could not be read)".to_string(),
|
|
};
|
|
let entries: Vec<crate::repo_digest::FileEntry> = tree
|
|
.get("tree")
|
|
.and_then(|t| t.as_array())
|
|
.map(|items| {
|
|
items
|
|
.iter()
|
|
.filter(|e| e.get("type").and_then(|v| v.as_str()) == Some("blob"))
|
|
.filter_map(|e| {
|
|
Some(crate::repo_digest::FileEntry {
|
|
path: e.get("path")?.as_str()?.to_string(),
|
|
size: e.get("size").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
|
|
})
|
|
})
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
// Fetch in priority order until the budget is spent. Requested serially and
|
|
// capped: this runs inside one API request, and a repo with 500 useful files
|
|
// must not turn a proposal into 500 round trips.
|
|
let mut fetched: Vec<(String, String)> = Vec::new();
|
|
let mut spent = 0usize;
|
|
for e in crate::repo_digest::priority(&entries).into_iter().take(40) {
|
|
if spent >= crate::repo_digest::CONTENT_BUDGET {
|
|
break;
|
|
}
|
|
let raw = format!(
|
|
"https://git.redclaw.dev/api/v1/repos/{owner}/{name}/raw/{}?ref={branch}",
|
|
e.path
|
|
);
|
|
if let Ok(r) = auth(client.get(&raw)).send().await {
|
|
if r.status().is_success() {
|
|
if let Ok(text) = r.text().await {
|
|
spent += text.len().min(crate::repo_digest::PER_FILE_CAP);
|
|
fetched.push((e.path.clone(), text));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
crate::repo_digest::render(&entries, &crate::repo_digest::fit(fetched))
|
|
}
|
|
|
|
const PLAN_SYSTEM: &str = "You decide what ONE software mission actually does — its phases, in order. \
|
|
Each phase is a full agent run against the same repository checkout: the next phase sees the tree the \
|
|
previous one left. They run SEQUENTIALLY, so phases are expensive and a handoff loses context at every \
|
|
step.\n\n\
|
|
Propose the FEWEST phases that genuinely need to be separate. ONE phase is usually the right answer, and \
|
|
is always the right answer for a self-contained change: splitting one change into plan → implement → \
|
|
test is a documented anti-pattern, not thoroughness — a single agent doing all three in one pass keeps \
|
|
the context that makes the later steps good. A second phase earns its place only when it depends on \
|
|
something the first phase could not have known when it started.\n\n\
|
|
Every phase needs a `task`: the specific instruction for THAT phase, not a restatement of the mission. \
|
|
An agent receives the mission description plus its own task, so a vague task means an agent guessing \
|
|
which part of the mission is its share.\n\n\
|
|
`done_when` is judged afterwards by a separate model reading the repository, so write it as something \
|
|
observable in the tree — a file that exists, a suite that passes — never as an intention. \
|
|
`done_when_check` is a SHELL COMMAND that must exit 0; it is enforced while the agent still works, so \
|
|
prefer it when the condition is mechanical. Set `allow_empty` true only for a phase whose job is to \
|
|
verify rather than to change files.\n\n\
|
|
ALWAYS respond with STRICT JSON ONLY, no prose and no markdown: \
|
|
{\"phases\":[{\"kind\":\"coding\",\"task\":\"...\",\"done_when\":null|\"...\",\
|
|
\"done_when_check\":null|\"...\",\"allow_empty\":null|true|false}]}";
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct PlanProposalResponse {
|
|
pub id: Uuid,
|
|
pub plan: Value,
|
|
pub author_model: String,
|
|
pub status: String,
|
|
}
|
|
|
|
/// `POST /api/missions/{id}/plan-proposals` — ask the model for a phase plan.
|
|
pub async fn suggest(
|
|
State(state): State<AppState>,
|
|
Authed(user): Authed,
|
|
Path(id): Path<Uuid>,
|
|
) -> Result<Json<PlanProposalResponse>, 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)?;
|
|
|
|
let prompt = format!(
|
|
"MISSION: {}\n\nDESCRIPTION:\n{}\n\n=== THE REPOSITORY ===\n{}\n=== END REPOSITORY \
|
|
===\n\nPlan for the repository as it ACTUALLY IS, not as the description implies it \
|
|
might be. If the work needs something absent — a benchmark harness, a test suite, a \
|
|
config file — the phase that needs it must CREATE it, and its task must say so. If the \
|
|
description asks for something this code cannot support (optimising a function with \
|
|
nothing to optimise, testing a module that does not exist), say so in the task text and \
|
|
plan the phase that would establish the truth, rather than a phase that must fail.\n\n\
|
|
NOTE: a mission agent has NO package-registry access — it cannot add dependencies. A \
|
|
phase needing tooling must build it from the standard library or from what is already \
|
|
vendored here.\n\nPHASE KINDS YOU MAY USE (nothing else runs): {}\nCEILING: \
|
|
{MAX_PHASES} phases.\n\nPropose the plan now (JSON only).",
|
|
mission.title,
|
|
mission.description.as_deref().unwrap_or("(none)"),
|
|
repo_digest(&state.pool, id).await,
|
|
PLANNABLE_KINDS.join(", "),
|
|
);
|
|
|
|
// The stored `author_model` is whichever link of the fallback chain
|
|
// actually answered — see `subscription::complete_with_fallback`.
|
|
let (raw, author_model) = crate::subscription::complete_with_fallback(
|
|
&state.runtime,
|
|
PLAN_SYSTEM,
|
|
&prompt,
|
|
PLANNER_MODEL,
|
|
2000,
|
|
false,
|
|
)
|
|
.await
|
|
.map_err(|e| {
|
|
eprintln!("mission {id}: plan proposal failed: {e}");
|
|
crate::subscription::as_api_error(&e)
|
|
})?;
|
|
|
|
let parsed: Value = crate::routes::claws::extract_json(&raw).ok_or_else(|| {
|
|
eprintln!("mission {id}: planner returned no JSON: {raw}");
|
|
ApiError::BadRequest
|
|
})?;
|
|
let plan: Plan = serde_json::from_value(parsed.clone()).map_err(|e| {
|
|
eprintln!("mission {id}: planner JSON is not a plan ({e}): {parsed}");
|
|
ApiError::BadRequest
|
|
})?;
|
|
// Validated BEFORE storing, so a stored proposal is always one that could be
|
|
// approved — the failure belongs to the model, not to whoever clicks
|
|
// approve later.
|
|
if let Err(why) = plan.validate() {
|
|
eprintln!("mission {id}: planner proposed an unrunnable plan: {why}");
|
|
return Err(ApiError::BadRequest);
|
|
}
|
|
|
|
let pid = Uuid::now_v7();
|
|
let stored = serde_json::to_value(&plan).map_err(|_| ApiError::Internal)?;
|
|
cm_db::repo::mission_plan_proposals::insert(
|
|
&state.pool,
|
|
pid,
|
|
id,
|
|
ws.as_uuid().to_owned(),
|
|
&stored,
|
|
&author_model,
|
|
)
|
|
.await
|
|
.map_err(|e| {
|
|
eprintln!("mission {id}: could not store plan proposal: {e}");
|
|
ApiError::Internal
|
|
})?;
|
|
eprintln!(
|
|
"mission_plan: mission {id} — {author_model} proposed {} phase(s): {}",
|
|
plan.phases.len(),
|
|
plan.phases
|
|
.iter()
|
|
.map(|p| p.kind.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join(" → ")
|
|
);
|
|
|
|
Ok(Json(PlanProposalResponse {
|
|
id: pid,
|
|
plan: stored,
|
|
author_model,
|
|
status: "proposed".into(),
|
|
}))
|
|
}
|
|
|
|
/// `GET /api/missions/{id}/plan-proposals`
|
|
pub async fn list(
|
|
State(state): State<AppState>,
|
|
Authed(user): Authed,
|
|
Path(id): Path<Uuid>,
|
|
) -> Result<Json<Vec<cm_db::repo::mission_plan_proposals::MissionPlanProposal>>, ApiError> {
|
|
let rows = cm_db::repo::mission_plan_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 {
|
|
pub status: String,
|
|
#[serde(default)]
|
|
pub note: Option<String>,
|
|
}
|
|
|
|
/// `POST /api/missions/{id}/plan-proposals/{pid}/decide`
|
|
///
|
|
/// Approving REPLACES the mission's phases. Draft-only: re-planning a mission
|
|
/// whose phases have started would discard work that already ran, and the phase
|
|
/// rows are what every downstream sweep keys off.
|
|
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_plan_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_plan_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}: plan approval refused — mission is {}", mission.status);
|
|
return Err(ApiError::BadRequest);
|
|
}
|
|
|
|
let plan: Plan = serde_json::from_value(proposal.plan.clone()).map_err(|e| {
|
|
eprintln!("mission {id}: stored plan {pid} does not parse ({e})");
|
|
ApiError::Internal
|
|
})?;
|
|
// Re-validated at approval. The stored plan passed once, but `PLANNABLE_KINDS`
|
|
// and the config registry are properties of the BUILD — a proposal made
|
|
// before a deploy could name a kind this build no longer dispatches.
|
|
if let Err(why) = plan.validate() {
|
|
eprintln!("mission {id}: plan {pid} is no longer runnable: {why}");
|
|
let _ = cm_db::repo::mission_plan_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);
|
|
}
|
|
|
|
let phases = plan.phases();
|
|
let claimed = cm_db::repo::mission_plan_proposals::approve_and_apply(
|
|
&state.pool,
|
|
pid,
|
|
id,
|
|
ws.as_uuid().to_owned(),
|
|
&phases,
|
|
body.note.as_deref(),
|
|
Some(user.user_id.as_uuid().to_owned()),
|
|
)
|
|
.await
|
|
.map_err(|e| {
|
|
eprintln!("mission {id}: could not apply plan {pid}: {e}");
|
|
ApiError::Internal
|
|
})?;
|
|
if !claimed {
|
|
return Err(ApiError::BadRequest);
|
|
}
|
|
|
|
eprintln!(
|
|
"mission_plan: mission {id} now runs a {}-phase model-authored plan from proposal {pid}",
|
|
phases.len()
|
|
);
|
|
Ok(Json(json!({
|
|
"status": "approved",
|
|
"phases": phases.iter().map(|(k, i, _)| json!({"kind": k, "order_idx": i})).collect::<Vec<_>>(),
|
|
})))
|
|
}
|