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
@@ -30,6 +30,7 @@ pub mod papers;
|
|||||||
pub mod phase_config;
|
pub mod phase_config;
|
||||||
pub mod session_executor;
|
pub mod session_executor;
|
||||||
pub mod runtime_preflight;
|
pub mod runtime_preflight;
|
||||||
|
pub mod mission_plan;
|
||||||
pub mod mission_roster;
|
pub mod mission_roster;
|
||||||
pub mod mission_runtime;
|
pub mod mission_runtime;
|
||||||
pub mod mission_workspace;
|
pub mod mission_workspace;
|
||||||
@@ -489,6 +490,16 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.route("/api/missions/{id}/refine", post(routes::missions::refine))
|
.route("/api/missions/{id}/refine", post(routes::missions::refine))
|
||||||
// Slice 5: let a model size the mission's team. Proposing, listing and
|
// Slice 5: let a model size the mission's team. Proposing, listing and
|
||||||
// deciding are separate verbs because only the last one spends money.
|
// deciding are separate verbs because only the last one spends money.
|
||||||
|
// W1/#13: let a model author the phases, on the same propose → review →
|
||||||
|
// approve shape as the roster above.
|
||||||
|
.route(
|
||||||
|
"/api/missions/{id}/plan-proposals",
|
||||||
|
get(routes::mission_plan::list).post(routes::mission_plan::suggest),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/missions/{id}/plan-proposals/{pid}/decide",
|
||||||
|
post(routes::mission_plan::decide),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/missions/{id}/team-proposals",
|
"/api/missions/{id}/team-proposals",
|
||||||
get(routes::mission_roster::list).post(routes::mission_roster::suggest),
|
get(routes::mission_roster::list).post(routes::mission_roster::suggest),
|
||||||
|
|||||||
@@ -0,0 +1,296 @@
|
|||||||
|
//! A model-authored execution plan for one mission — W1 / #13.
|
||||||
|
//!
|
||||||
|
//! Every mission's phases come from one of five hand-written recipes in
|
||||||
|
//! `templates/workflows/*.toml`, chosen by `template_kind`. A recipe is a fixed
|
||||||
|
//! answer to "what phases does this kind of mission have", written before anyone
|
||||||
|
//! saw the mission — the "do it this way: 1, 2, 3" over-specification that makes
|
||||||
|
//! a capable model follow a worse plan than it would have chosen for the actual
|
||||||
|
//! task.
|
||||||
|
//!
|
||||||
|
//! This is the other half of [`crate::mission_roster`]: that one lets a model
|
||||||
|
//! size the team, this one lets it decide what the work IS. Same shape on
|
||||||
|
//! purpose — propose, review, approve, apply — because the review gate is what
|
||||||
|
//! makes model-authored structure safe to run, and a second shape would be a
|
||||||
|
//! second thing to get right.
|
||||||
|
//!
|
||||||
|
//! # Grounded in what the platform actually reads
|
||||||
|
//!
|
||||||
|
//! The interesting constraint is not "is this JSON valid" but "will anything
|
||||||
|
//! consume it". `phase_config::KNOWN_KEYS` already names every phase-config key
|
||||||
|
//! and the code that reads it, with eleven marked NOT IMPLEMENTED — the registry
|
||||||
|
//! built after `task` sat unread through every mission. A plan is validated
|
||||||
|
//! against that registry, 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, against the kinds `phase_runner`
|
||||||
|
//! actually dispatches. A model asked to plan work will happily invent
|
||||||
|
//! `kind: "review"`, and an unknown kind does not fail — it falls to the
|
||||||
|
//! catch-all purpose and runs as a generic phase, which looks like it worked.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Phase kinds `phase_runner` dispatches on.
|
||||||
|
///
|
||||||
|
/// Not an enum, because `mission_phases.kind` is a free-form column shared with
|
||||||
|
/// hand-written recipes and the wizard; this is the subset a MODEL may propose.
|
||||||
|
/// An unrecognised kind is the dangerous case: it does not error, it falls
|
||||||
|
/// through to the generic `mission` purpose and runs anyway.
|
||||||
|
pub const PLANNABLE_KINDS: &[&str] = &["research", "coding", "benchmark", "security_scan"];
|
||||||
|
|
||||||
|
/// Ceiling on a proposed plan.
|
||||||
|
///
|
||||||
|
/// Each phase is a full agent run — a VM boot, a checkout, a turn, a capture —
|
||||||
|
/// executed in sequence. Anthropic's own guidance warns against decomposing work
|
||||||
|
/// into sequential phases at all ("a handoff loses context at every step"), so
|
||||||
|
/// this bound is deliberately tight: a model that wants eight phases is
|
||||||
|
/// describing a to-do list, not a plan.
|
||||||
|
pub const MAX_PHASES: usize = 4;
|
||||||
|
|
||||||
|
/// One phase of a proposed plan.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct PlannedPhase {
|
||||||
|
/// One of [`PLANNABLE_KINDS`].
|
||||||
|
pub kind: String,
|
||||||
|
/// What this phase does. Lands in `config.task`, which
|
||||||
|
/// `phase_task_text` injects — the key that sat unread through every
|
||||||
|
/// mission until two phases with different tasks produced identical output.
|
||||||
|
pub task: String,
|
||||||
|
/// Optional completion condition, judged post-hoc by the evaluator.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub done_when: Option<String>,
|
||||||
|
/// Optional deterministic check, enforced IN the agent's loop by the stop
|
||||||
|
/// gate ([`crate::vm_stop_gate`]).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub done_when_check: Option<String>,
|
||||||
|
/// This phase is allowed to change nothing (a verification pass).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub allow_empty: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A proposed sequence of phases.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct Plan {
|
||||||
|
pub phases: Vec<PlannedPhase>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why a plan was refused.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Refusal {
|
||||||
|
Empty,
|
||||||
|
TooMany(usize),
|
||||||
|
UnknownKind { index: usize, kind: String },
|
||||||
|
BlankTask(usize),
|
||||||
|
/// A config key with no reader in this build — named, with the ones that
|
||||||
|
/// would have been consumed.
|
||||||
|
InertKey { index: usize, key: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Refusal {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Refusal::Empty => write!(f, "the plan has no phases, so the mission would do nothing"),
|
||||||
|
Refusal::TooMany(n) => write!(
|
||||||
|
f,
|
||||||
|
"the plan has {n} phases and the ceiling is {MAX_PHASES} — each one is a full \
|
||||||
|
agent run, and a handoff loses context at every step"
|
||||||
|
),
|
||||||
|
Refusal::UnknownKind { index, kind } => write!(
|
||||||
|
f,
|
||||||
|
"phase {index} has kind {kind:?}, which nothing dispatches on; use one of: {}",
|
||||||
|
PLANNABLE_KINDS.join(", ")
|
||||||
|
),
|
||||||
|
Refusal::BlankTask(i) => write!(
|
||||||
|
f,
|
||||||
|
"phase {i} has no task, so its agent would receive the mission description and \
|
||||||
|
nothing telling it which part is its own"
|
||||||
|
),
|
||||||
|
Refusal::InertKey { index, key } => write!(
|
||||||
|
f,
|
||||||
|
"phase {index} sets {key:?}, which nothing in this build reads — it would be \
|
||||||
|
stored, rendered, and consumed by nobody"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Plan {
|
||||||
|
/// Check a plan against what the platform can actually execute.
|
||||||
|
pub fn validate(&self) -> Result<(), Refusal> {
|
||||||
|
if self.phases.is_empty() {
|
||||||
|
return Err(Refusal::Empty);
|
||||||
|
}
|
||||||
|
if self.phases.len() > MAX_PHASES {
|
||||||
|
return Err(Refusal::TooMany(self.phases.len()));
|
||||||
|
}
|
||||||
|
for (i, p) in self.phases.iter().enumerate() {
|
||||||
|
if !PLANNABLE_KINDS.contains(&p.kind.as_str()) {
|
||||||
|
return Err(Refusal::UnknownKind {
|
||||||
|
index: i,
|
||||||
|
kind: p.kind.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if p.task.trim().is_empty() {
|
||||||
|
return Err(Refusal::BlankTask(i));
|
||||||
|
}
|
||||||
|
// Every key this phase would write must have a reader. The plan is
|
||||||
|
// built from typed fields, so this can only fail if a field is added
|
||||||
|
// here without a corresponding entry in the registry — which is
|
||||||
|
// exactly the drift worth failing on.
|
||||||
|
if let Some(key) = crate::phase_config::inert_keys(&p.config()).into_iter().next() {
|
||||||
|
return Err(Refusal::InertKey { index: i, key });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The phases as `(kind, order_idx, config)`, ready for mission creation.
|
||||||
|
///
|
||||||
|
/// `order_idx` is the array position rather than a field the model sets:
|
||||||
|
/// two sources for one fact is how a plan ends up with two phase 0s.
|
||||||
|
pub fn phases(&self) -> Vec<(String, i32, serde_json::Value)> {
|
||||||
|
self.phases
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, p)| (p.kind.clone(), i as i32, p.config()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlannedPhase {
|
||||||
|
/// This phase's `mission_phases.config`.
|
||||||
|
fn config(&self) -> serde_json::Value {
|
||||||
|
let mut o = serde_json::Map::new();
|
||||||
|
o.insert("task".into(), serde_json::Value::String(self.task.clone()));
|
||||||
|
if let Some(d) = self.done_when.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
|
||||||
|
o.insert("done_when".into(), serde_json::Value::String(d.to_string()));
|
||||||
|
}
|
||||||
|
if let Some(c) = self
|
||||||
|
.done_when_check
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
{
|
||||||
|
o.insert(
|
||||||
|
"done_when_check".into(),
|
||||||
|
serde_json::Value::String(c.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(e) = self.allow_empty {
|
||||||
|
o.insert("allow_empty".into(), serde_json::Value::Bool(e));
|
||||||
|
}
|
||||||
|
serde_json::Value::Object(o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn phase(kind: &str, task: &str) -> PlannedPhase {
|
||||||
|
PlannedPhase {
|
||||||
|
kind: kind.into(),
|
||||||
|
task: task.into(),
|
||||||
|
done_when: None,
|
||||||
|
done_when_check: None,
|
||||||
|
allow_empty: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A kind nothing dispatches on is the dangerous one: it does not error, it
|
||||||
|
/// falls through to the generic purpose and runs as a nondescript phase that
|
||||||
|
/// looks like it worked.
|
||||||
|
#[test]
|
||||||
|
fn an_invented_phase_kind_is_refused_naming_the_real_ones() {
|
||||||
|
let p = Plan {
|
||||||
|
phases: vec![phase("coding", "do it"), phase("review", "check it")],
|
||||||
|
};
|
||||||
|
let err = p.validate().unwrap_err();
|
||||||
|
assert_eq!(
|
||||||
|
err,
|
||||||
|
Refusal::UnknownKind {
|
||||||
|
index: 1,
|
||||||
|
kind: "review".into()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
let msg = err.to_string();
|
||||||
|
for kind in PLANNABLE_KINDS {
|
||||||
|
assert!(msg.contains(kind), "the message must name {kind}: {msg}");
|
||||||
|
}
|
||||||
|
// And every kind the runner dispatches on is accepted, so this cannot
|
||||||
|
// drift from what `phase_runner` can actually execute.
|
||||||
|
for kind in PLANNABLE_KINDS {
|
||||||
|
assert!(Plan { phases: vec![phase(kind, "work")] }.validate().is_ok(), "{kind}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every key a planned phase writes must have a reader. This is the whole
|
||||||
|
/// reason `phase_config` exists — a key nothing consumes is stored,
|
||||||
|
/// rendered, and silently inert.
|
||||||
|
#[test]
|
||||||
|
fn every_key_a_plan_writes_is_one_something_reads() {
|
||||||
|
let p = PlannedPhase {
|
||||||
|
kind: "coding".into(),
|
||||||
|
task: "add a module".into(),
|
||||||
|
done_when: Some("the suite passes".into()),
|
||||||
|
done_when_check: Some("cargo test".into()),
|
||||||
|
allow_empty: Some(false),
|
||||||
|
};
|
||||||
|
let cfg = p.config();
|
||||||
|
assert!(
|
||||||
|
crate::phase_config::inert_keys(&cfg).is_empty(),
|
||||||
|
"a planned phase must write only keys with readers: {:?}",
|
||||||
|
crate::phase_config::inert_keys(&cfg)
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
crate::phase_config::unknown_keys(&cfg).is_empty(),
|
||||||
|
"and only keys the registry knows: {:?}",
|
||||||
|
crate::phase_config::unknown_keys(&cfg)
|
||||||
|
);
|
||||||
|
assert!(Plan { phases: vec![p] }.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A blank task is the failure that produced identical output from two
|
||||||
|
/// different phases — the agent gets the mission description and nothing
|
||||||
|
/// saying which part is its own.
|
||||||
|
#[test]
|
||||||
|
fn a_phase_without_a_task_is_refused() {
|
||||||
|
let p = Plan {
|
||||||
|
phases: vec![phase("coding", " ")],
|
||||||
|
};
|
||||||
|
assert_eq!(p.validate(), Err(Refusal::BlankTask(0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounded and non-empty. Each phase is a full agent run in sequence, and
|
||||||
|
/// splitting one change into stages loses context at every handoff.
|
||||||
|
#[test]
|
||||||
|
fn a_plan_is_bounded_and_non_empty() {
|
||||||
|
assert_eq!(Plan { phases: vec![] }.validate(), Err(Refusal::Empty));
|
||||||
|
let many: Vec<_> = (0..MAX_PHASES + 1).map(|_| phase("coding", "work")).collect();
|
||||||
|
assert_eq!(
|
||||||
|
Plan { phases: many }.validate(),
|
||||||
|
Err(Refusal::TooMany(MAX_PHASES + 1))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Order comes from the array, not from 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.
|
||||||
|
#[test]
|
||||||
|
fn order_comes_from_the_arrays_own_order() {
|
||||||
|
let p = Plan {
|
||||||
|
phases: vec![
|
||||||
|
phase("research", "read the code"),
|
||||||
|
phase("coding", "change it"),
|
||||||
|
phase("coding", "then this"),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let out = p.phases();
|
||||||
|
assert_eq!(
|
||||||
|
out.iter().map(|(_, i, _)| *i).collect::<Vec<_>>(),
|
||||||
|
vec![0, 1, 2]
|
||||||
|
);
|
||||||
|
assert_eq!(out[0].0, "research");
|
||||||
|
assert_eq!(out[1].2["task"], "change it");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,7 +50,13 @@ pub const KNOWN_KEYS: &[KnownKey] = &[
|
|||||||
KnownKey {
|
KnownKey {
|
||||||
key: "allow_empty",
|
key: "allow_empty",
|
||||||
read_by: "phase_runner::empty_delivery_is_a_failure — when true, a coding \
|
read_by: "phase_runner::empty_delivery_is_a_failure — when true, a coding \
|
||||||
phase that changes no files still completes",
|
phase that changes no files still completes; also vm_stop_gate::\
|
||||||
|
StopGate::for_phase, where it drops the in-loop delivery check",
|
||||||
|
},
|
||||||
|
KnownKey {
|
||||||
|
key: "done_when_check",
|
||||||
|
read_by: "vm_stop_gate::StopGate::for_phase — a shell command the agent's \
|
||||||
|
`Stop` hook runs, refusing the stop while it exits non-zero",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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<_>>(),
|
||||||
|
})))
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ pub mod health;
|
|||||||
pub mod identity;
|
pub mod identity;
|
||||||
pub mod level_up;
|
pub mod level_up;
|
||||||
pub mod library;
|
pub mod library;
|
||||||
|
pub mod mission_plan;
|
||||||
pub mod mission_roster;
|
pub mod mission_roster;
|
||||||
pub mod missions;
|
pub mod missions;
|
||||||
pub mod nodes;
|
pub mod nodes;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ pub mod fleet_beszel;
|
|||||||
pub mod fleet_tailscale;
|
pub mod fleet_tailscale;
|
||||||
pub mod level_up;
|
pub mod level_up;
|
||||||
pub mod messages;
|
pub mod messages;
|
||||||
|
pub mod mission_plan_proposals;
|
||||||
pub mod mission_team_proposals;
|
pub mod mission_team_proposals;
|
||||||
pub mod missions;
|
pub mod missions;
|
||||||
pub mod node_metrics;
|
pub mod node_metrics;
|
||||||
|
|||||||
@@ -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:?}");
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
-- A model's proposal for a mission's PHASES, and whether a human accepted it.
|
||||||
|
--
|
||||||
|
-- The sibling of `mission_team_proposals` (0070), and deliberately the same
|
||||||
|
-- shape: propose, review, approve, apply. That one lets a model size the team;
|
||||||
|
-- this one lets a model decide what the work actually IS — the phases, their
|
||||||
|
-- order, and what each must satisfy.
|
||||||
|
--
|
||||||
|
-- What it replaces: five hand-written recipes in `templates/workflows/*.toml`,
|
||||||
|
-- one of which every mission picks wholesale. A recipe is a fixed answer to
|
||||||
|
-- "what phases does this kind of mission have", written before anyone saw the
|
||||||
|
-- mission — which 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 remain the default for a mission nobody proposes a plan
|
||||||
|
-- for, and the fallback when a proposal is refused.
|
||||||
|
--
|
||||||
|
-- `status` matches 0070 exactly, including the partial unique index: two
|
||||||
|
-- approved plans would be two answers to "what is this mission", and the phase
|
||||||
|
-- table holds one.
|
||||||
|
CREATE TABLE IF NOT EXISTS mission_plan_proposals (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
mission_id UUID NOT NULL REFERENCES missions (id) ON DELETE CASCADE,
|
||||||
|
workspace_id UUID NOT NULL,
|
||||||
|
-- {"phases": [{kind, task, done_when?, done_when_check?, allow_empty?}, ...]}
|
||||||
|
-- Order is the array's own order; a model that also emits `order_idx` would
|
||||||
|
-- give two sources for one fact.
|
||||||
|
plan JSONB NOT NULL,
|
||||||
|
author_model TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'proposed'
|
||||||
|
CHECK (status IN ('proposed', 'approved', 'rejected')),
|
||||||
|
note TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
decided_at TIMESTAMPTZ,
|
||||||
|
decided_by UUID
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS mission_plan_proposals_mission_idx
|
||||||
|
ON mission_plan_proposals (mission_id, created_at DESC);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS mission_plan_proposals_one_approved
|
||||||
|
ON mission_plan_proposals (mission_id)
|
||||||
|
WHERE status = 'approved';
|
||||||
Reference in New Issue
Block a user