feat(missions): W1/#13 — let a model author the mission's phases
The last unstarted item from the missions-as-workflows plan, and the other half
of Slice 5: that one lets a model size the TEAM, this lets it decide what the
work IS.
Every mission's phases come from one of five hand-written recipes in
`templates/workflows/*.toml`, chosen by `template_kind` before anyone saw the
mission. That is the "do it this way: 1, 2, 3" over-specification that makes a
capable model follow a worse plan than it would have chosen. The recipes stay —
they are still the default for a mission nobody proposes a plan for, and the
fallback when a proposal is refused.
Same three verbs and the same review gate as the roster, deliberately: propose
and decide are separate because only the second changes a mission, and a second
shape would be a second thing to get right. Approving REPLACES the phases (a
plan is an answer to "what is this mission", not an addition to one), draft-only.
GROUNDED IN WHAT THE PLATFORM ACTUALLY READS, which is the part that makes this
more than a copy. `phase_config::KNOWN_KEYS` already names every phase-config key
and the code that reads it — the registry built after `task` sat unread through
every mission. A plan is validated against it, so a model cannot propose a phase
whose settings nothing will act on: the failure that registry exists to EXPOSE is
one this path cannot create. Phase kinds are checked the same way, because an
unknown kind does not error — it falls through to the catch-all purpose and runs
as a generic phase that looks like it worked.
TWO THINGS THE WORK ITSELF FOUND, both the same shape:
- `done_when_check` — the stop-gate key added earlier today — was never
registered in `phase_config`, so every mission that set it has been logging
it as an unknown key. Found by a test written for a different purpose, which
is the registry doing exactly its job. Now registered with its reader.
- `done_when` and `max_iterations` are COLUMNS promoted out of config by
`missions::create`; the evaluator sweep filters on the column in SQL every
tick. My first insert wrote the config blob alone, which would have stored a
plan's completion condition where nothing judges it. NEGATIVE CONTROL run:
binding NULL instead of the promoted value fails
`an_approved_plan_replaces_the_missions_phases`.
`order_idx` comes from the array's own order rather than a field the model sets:
two sources for one fact is how a plan ends up with two phase 0s, and order_idx
is what `start_pending_phases` sequences on.
MAX_PHASES is 4 and the prompt argues for one. Each phase is a full agent run in
sequence, and splitting one change into plan → implement → test is the documented
anti-pattern — a single agent doing all three keeps the context that makes the
later steps good.
543 tests pass, clippy clean. Migration 0072.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a48d78f8eb
commit
a33dbdcdc3
@@ -0,0 +1,249 @@
|
||||
//! `/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";
|
||||
|
||||
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\nThis mission {} a repository.\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)"),
|
||||
if mission.repo_id.is_some() { "HAS" } else { "has NO" },
|
||||
PLANNABLE_KINDS.join(", "),
|
||||
);
|
||||
|
||||
let raw = state
|
||||
.runtime
|
||||
.complete(PLAN_SYSTEM, &prompt, PLANNER_MODEL, 2000, false)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
eprintln!("mission {id}: plan proposal failed: {e}");
|
||||
ApiError::Internal
|
||||
})?;
|
||||
|
||||
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,
|
||||
PLANNER_MODEL,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
eprintln!("mission {id}: could not store plan proposal: {e}");
|
||||
ApiError::Internal
|
||||
})?;
|
||||
eprintln!(
|
||||
"mission_plan: mission {id} — {PLANNER_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: PLANNER_MODEL.to_string(),
|
||||
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<_>>(),
|
||||
})))
|
||||
}
|
||||
Reference in New Issue
Block a user