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,231 @@
|
||||
//! Model-authored mission PLANS — the phase list — and whether a human
|
||||
//! accepted them.
|
||||
//!
|
||||
//! See `migrations/0070_mission_plan_proposals.sql` for why a proposal is
|
||||
//! persisted rather than applied on arrival.
|
||||
|
||||
use crate::DbError;
|
||||
use serde_json::Value;
|
||||
use sqlx::PgPool;
|
||||
use time::OffsetDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct MissionPlanProposal {
|
||||
pub id: Uuid,
|
||||
pub mission_id: Uuid,
|
||||
pub plan: Value,
|
||||
pub author_model: String,
|
||||
pub status: String,
|
||||
pub note: Option<String>,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub created_at: OffsetDateTime,
|
||||
#[serde(with = "time::serde::rfc3339::option")]
|
||||
pub decided_at: Option<OffsetDateTime>,
|
||||
}
|
||||
|
||||
pub async fn insert(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
mission_id: Uuid,
|
||||
workspace_id: Uuid,
|
||||
plan: &Value,
|
||||
author_model: &str,
|
||||
) -> Result<(), DbError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO mission_plan_proposals
|
||||
(id, mission_id, workspace_id, plan, author_model)
|
||||
VALUES ($1, $2, $3, $4, $5)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(mission_id)
|
||||
.bind(workspace_id)
|
||||
.bind(plan)
|
||||
.bind(author_model)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every proposal for a mission, newest first. Rejected ones are included on
|
||||
/// purpose: what a human turned down is the only record of what the planner
|
||||
/// gets wrong.
|
||||
pub async fn list(
|
||||
pool: &PgPool,
|
||||
mission_id: Uuid,
|
||||
workspace_id: Uuid,
|
||||
) -> Result<Vec<MissionPlanProposal>, DbError> {
|
||||
let rows = sqlx::query_as::<_, (Uuid, Uuid, Value, String, String, Option<String>, OffsetDateTime, Option<OffsetDateTime>)>(
|
||||
"SELECT id, mission_id, plan, author_model, status, note, created_at, decided_at
|
||||
FROM mission_plan_proposals
|
||||
WHERE mission_id = $1 AND workspace_id = $2
|
||||
ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(mission_id)
|
||||
.bind(workspace_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(id, mission_id, plan, author_model, status, note, created_at, decided_at)| {
|
||||
MissionPlanProposal {
|
||||
id,
|
||||
mission_id,
|
||||
plan,
|
||||
author_model,
|
||||
status,
|
||||
note,
|
||||
created_at,
|
||||
decided_at,
|
||||
}
|
||||
},
|
||||
)
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn get(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
workspace_id: Uuid,
|
||||
) -> Result<Option<MissionPlanProposal>, DbError> {
|
||||
let row = sqlx::query_as::<_, (Uuid, Uuid, Value, String, String, Option<String>, OffsetDateTime, Option<OffsetDateTime>)>(
|
||||
"SELECT id, mission_id, plan, author_model, status, note, created_at, decided_at
|
||||
FROM mission_plan_proposals
|
||||
WHERE id = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(workspace_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(
|
||||
|(id, mission_id, plan, author_model, status, note, created_at, decided_at)| {
|
||||
MissionPlanProposal {
|
||||
id,
|
||||
mission_id,
|
||||
plan,
|
||||
author_model,
|
||||
status,
|
||||
note,
|
||||
created_at,
|
||||
decided_at,
|
||||
}
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
/// Approve a plan AND write its phases onto the mission, atomically.
|
||||
///
|
||||
/// One transaction, for the reason `mission_team_proposals::approve_and_apply`
|
||||
/// documents: a two-statement version left a proposal marked `approved` against
|
||||
/// a mission that never received it, and the partial unique index then makes
|
||||
/// that state permanent.
|
||||
///
|
||||
/// The mission's existing phases are REPLACED. A plan is an answer to "what is
|
||||
/// this mission", not an addition to the recipe's answer — merging the two would
|
||||
/// produce a phase list neither the model nor the recipe author intended. Only a
|
||||
/// draft mission is eligible (checked by the caller), so nothing in flight is
|
||||
/// discarded.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn approve_and_apply(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
mission_id: Uuid,
|
||||
workspace_id: Uuid,
|
||||
phases: &[(String, i32, Value)],
|
||||
note: Option<&str>,
|
||||
decided_by: Option<Uuid>,
|
||||
) -> Result<bool, DbError> {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
let claimed = sqlx::query(
|
||||
"UPDATE mission_plan_proposals
|
||||
SET status = 'approved', note = $3, decided_at = now(), decided_by = $4
|
||||
WHERE id = $1 AND workspace_id = $2 AND status = 'proposed'",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(workspace_id)
|
||||
.bind(note)
|
||||
.bind(decided_by)
|
||||
.execute(&mut *tx)
|
||||
.await?
|
||||
.rows_affected();
|
||||
if claimed != 1 {
|
||||
tx.rollback().await?;
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Scoped by workspace on the mission, so a proposal cannot rewrite the
|
||||
// phases of a mission in another workspace even if its own row were forged.
|
||||
let owned: i64 = sqlx::query_scalar(
|
||||
"SELECT count(*) FROM missions WHERE id = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(mission_id)
|
||||
.bind(workspace_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if owned != 1 {
|
||||
tx.rollback().await?;
|
||||
return Err(DbError::NotFound);
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM mission_phases WHERE mission_id = $1")
|
||||
.bind(mission_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
for (kind, order_idx, config) in phases {
|
||||
// `done_when` is PROMOTED out of the config into its column, exactly as
|
||||
// `missions::create` does. The evaluator sweep filters on the column in
|
||||
// SQL on every tick — a plan whose condition stayed in the JSONB blob
|
||||
// would be stored, rendered, and never judged, which is the same shape
|
||||
// as the unread `task` this whole registry exists because of.
|
||||
let done_when = config
|
||||
.get("done_when")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
sqlx::query(
|
||||
"INSERT INTO mission_phases
|
||||
(id, mission_id, kind, order_idx, status, config, done_when, max_iterations)
|
||||
VALUES ($1, $2, $3, $4, 'pending', $5, $6, 1)",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(mission_id)
|
||||
.bind(kind)
|
||||
.bind(order_idx)
|
||||
.bind(config)
|
||||
.bind(done_when)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Record a decision. Only a `proposed` row may be decided, so approving twice
|
||||
/// — a double-click, a retried request — cannot re-apply a roster to a mission
|
||||
/// that has since moved on. Returns whether this call was the one that decided.
|
||||
pub async fn decide(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
workspace_id: Uuid,
|
||||
status: &str,
|
||||
note: Option<&str>,
|
||||
decided_by: Option<Uuid>,
|
||||
) -> Result<bool, DbError> {
|
||||
let done = sqlx::query(
|
||||
"UPDATE mission_plan_proposals
|
||||
SET status = $3, note = $4, decided_at = now(), decided_by = $5
|
||||
WHERE id = $1 AND workspace_id = $2 AND status = 'proposed'",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(workspace_id)
|
||||
.bind(status)
|
||||
.bind(note)
|
||||
.bind(decided_by)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.rows_affected();
|
||||
Ok(done == 1)
|
||||
}
|
||||
Reference in New Issue
Block a user