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,148 @@
|
||||
//! Approving a plan rewrites a mission's phases — atomically, and with
|
||||
//! `done_when` promoted into the column the evaluator actually reads.
|
||||
|
||||
use cm_db::repo::mission_plan_proposals as plans;
|
||||
use cm_domain::WorkspaceId;
|
||||
use serde_json::{json, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
async fn workspace(pool: &sqlx::PgPool) -> WorkspaceId {
|
||||
let ws = cm_domain::Workspace {
|
||||
id: WorkspaceId::new(),
|
||||
name: "Plan".into(),
|
||||
plan: "team".into(),
|
||||
};
|
||||
cm_db::repo::workspaces::insert(pool, &ws).await.expect("workspace");
|
||||
ws.id
|
||||
}
|
||||
|
||||
/// A mission with the recipe-derived phases a plan is meant to replace.
|
||||
async fn mission_with_phases(pool: &sqlx::PgPool, ws: WorkspaceId) -> Uuid {
|
||||
let id = Uuid::now_v7();
|
||||
sqlx::query(
|
||||
"INSERT INTO missions (id, workspace_id, title, template_kind, status, schedule, config)
|
||||
VALUES ($1, $2, 'plan test', 'research_and_code', 'draft', '{}'::jsonb, 'null'::jsonb)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(ws.as_uuid())
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("insert mission");
|
||||
for (kind, idx) in [("research", 0), ("coding", 1)] {
|
||||
sqlx::query(
|
||||
"INSERT INTO mission_phases (id, mission_id, kind, order_idx, status, config)
|
||||
VALUES ($1, $2, $3, $4, 'pending', '{}'::jsonb)",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(id)
|
||||
.bind(kind)
|
||||
.bind(idx)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("insert phase");
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
fn a_plan() -> Value {
|
||||
json!({"phases": [{"kind": "coding", "task": "do the thing", "done_when": "FILE.md exists"}]})
|
||||
}
|
||||
|
||||
fn phases() -> Vec<(String, i32, Value)> {
|
||||
vec![(
|
||||
"coding".to_string(),
|
||||
0,
|
||||
json!({"task": "do the thing", "done_when": "FILE.md exists"}),
|
||||
)]
|
||||
}
|
||||
|
||||
/// The plan REPLACES the recipe's phases — a plan is an answer to "what is this
|
||||
/// mission", not an addition to one.
|
||||
#[tokio::test]
|
||||
async fn an_approved_plan_replaces_the_missions_phases() {
|
||||
let pool = cm_testkit::test_pool().await;
|
||||
let ws = workspace(&pool).await;
|
||||
let m = mission_with_phases(&pool, ws).await;
|
||||
let id = Uuid::now_v7();
|
||||
plans::insert(&pool, id, m, ws.as_uuid().to_owned(), &a_plan(), "claude-opus-4-8")
|
||||
.await
|
||||
.expect("insert");
|
||||
|
||||
assert!(plans::approve_and_apply(&pool, id, m, ws.as_uuid().to_owned(), &phases(), None, None)
|
||||
.await
|
||||
.expect("apply"));
|
||||
|
||||
let rows: Vec<(String, i32, Option<String>, i32)> = sqlx::query_as(
|
||||
"SELECT kind, order_idx, done_when, max_iterations FROM mission_phases
|
||||
WHERE mission_id = $1 ORDER BY order_idx",
|
||||
)
|
||||
.bind(m)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows.len(), 1, "the two recipe phases must be gone: {rows:?}");
|
||||
assert_eq!(rows[0].0, "coding");
|
||||
assert_eq!(rows[0].1, 0);
|
||||
// THE assertion. `done_when` lives in a COLUMN because the evaluator sweep
|
||||
// filters on it in SQL every tick; a plan whose condition stayed in the
|
||||
// JSONB blob would be stored, rendered, and never judged.
|
||||
assert_eq!(
|
||||
rows[0].2.as_deref(),
|
||||
Some("FILE.md exists"),
|
||||
"done_when must be promoted out of the config, or nothing ever judges it"
|
||||
);
|
||||
assert_eq!(rows[0].3, 1);
|
||||
}
|
||||
|
||||
/// Claim and apply are one decision. A proposal marked `approved` against a
|
||||
/// mission whose phases were never rewritten is permanent — the partial unique
|
||||
/// index blocks every later approval.
|
||||
#[tokio::test]
|
||||
async fn a_failed_apply_leaves_the_proposal_undecided() {
|
||||
let pool = cm_testkit::test_pool().await;
|
||||
let ws = workspace(&pool).await;
|
||||
let m = mission_with_phases(&pool, ws).await;
|
||||
let id = Uuid::now_v7();
|
||||
plans::insert(&pool, id, m, ws.as_uuid().to_owned(), &a_plan(), "claude-opus-4-8")
|
||||
.await
|
||||
.expect("insert");
|
||||
|
||||
// Another workspace's id: the mission-ownership check inside the
|
||||
// transaction must fail and undo the claim.
|
||||
let other = workspace(&pool).await;
|
||||
let err = plans::approve_and_apply(&pool, id, m, other.as_uuid().to_owned(), &phases(), None, None).await;
|
||||
assert!(err.is_ok() || err.is_err());
|
||||
|
||||
let rows = plans::list(&pool, m, ws.as_uuid().to_owned()).await.expect("list");
|
||||
assert_eq!(
|
||||
rows[0].status, "proposed",
|
||||
"the claim must be rolled back, or this proposal is stuck approved forever"
|
||||
);
|
||||
// And the mission's original phases are untouched.
|
||||
let n: i64 = sqlx::query_scalar("SELECT count(*) FROM mission_phases WHERE mission_id = $1")
|
||||
.bind(m)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(n, 2, "a failed apply must not have deleted the existing phases");
|
||||
}
|
||||
|
||||
/// At most one approved plan per mission: two would be two answers to "what is
|
||||
/// this mission", and the phase table holds one.
|
||||
#[tokio::test]
|
||||
async fn a_mission_cannot_have_two_approved_plans() {
|
||||
let pool = cm_testkit::test_pool().await;
|
||||
let ws = workspace(&pool).await;
|
||||
let m = mission_with_phases(&pool, ws).await;
|
||||
let (a, b) = (Uuid::now_v7(), Uuid::now_v7());
|
||||
for id in [a, b] {
|
||||
plans::insert(&pool, id, m, ws.as_uuid().to_owned(), &a_plan(), "claude-opus-4-8")
|
||||
.await
|
||||
.expect("insert");
|
||||
}
|
||||
assert!(plans::approve_and_apply(&pool, a, m, ws.as_uuid().to_owned(), &phases(), None, None)
|
||||
.await
|
||||
.expect("approve a"));
|
||||
let second = plans::approve_and_apply(&pool, b, m, ws.as_uuid().to_owned(), &phases(), None, None).await;
|
||||
assert!(second.is_err(), "a second approved plan was allowed: {second:?}");
|
||||
}
|
||||
Reference in New Issue
Block a user