Claude writes the code and Claude judges it. That is a correlated failure: the
model that talked itself into a shortcut is the one disposed to accept it, and it
is the structural cause of the "early victory" failure Anthropic documents and of
our own Goodhart incident.
`glm` and `kimi` are both already registered in production, so the fix needed no
new credential path.
THE UNLOCK: `judge_with_tools` took `&AnthropicProvider`, but `LlmProvider` is a
single method — `stream(ChatRequest)` — and the loop only ever used that. The
concrete type was incidental. Widening it to `&dyn LlmProvider` means a
cross-provider judge runs the SAME allow-listed command loop. Before, independence
and real verification were mutually exclusive: the tool loop existed only on the
subscription path and every other route "judged claims only", so choosing an
independent judge meant giving up the checks that make a verdict evidence. GLM is
registered in anthropic format, so tool calling reaches it unchanged.
`CLAWMATES_VALIDATOR_MODEL` (e.g. `glm:glm-4.7`) selects it. Three refusals, each
protecting the claim the field makes:
- a spec in the implementer's own family is rejected, not used — `opus` judging
`sonnet` is not independence, they share a lineage and most failure modes
- a spec naming a provider this deployment never registered is rejected.
`Runtime::resolve_provider` silently falls back to the DEFAULT provider when
the registry has no such name, which would hand back Claude while the caller
believed it had GLM. Detectable because the returned model keeps its `name:`
prefix, so it is checked rather than trusted.
- an independent judge that FAILS does not fall through to the house judge. A
verdict quietly produced by a same-family model would claim a property it does
not have. The pass stays unmet, says why, and the next sweep retries.
`Verdict.independent` records it, `#[serde(default)]` so verdicts stored before
this field read back as not independent — which is what they were. An unrecognised
model family resolves to "unknown", never to ours: guessing would report
independence nobody established.
474 tests pass, clippy clean. Not yet enabled in production — the env var is unset,
so behaviour is identical until it is set deliberately.
367 lines
13 KiB
Rust
367 lines
13 KiB
Rust
//! Coverage for goal conditions and phase iteration (migration 0061).
|
|
//!
|
|
//! These tests exercise the SQL directly rather than the sweep loop, because
|
|
//! the part that is easy to get wrong is the *iteration scoping*: "are this
|
|
//! phase's runs all finished?" must ask about the CURRENT pass. Without that,
|
|
//! pass 1's completed rows satisfy pass 2 the instant it is enqueued and the
|
|
//! phase completes without doing any work.
|
|
//!
|
|
//! What this locks in:
|
|
//! * A phase with no `done_when` still goes running -> completed on terminal
|
|
//! runs (the regression guard: existing missions are unaffected).
|
|
//! * A phase with `done_when` goes running -> evaluating instead.
|
|
//! * A failed run fails the phase outright, condition or not.
|
|
//! * Pass 2 is not satisfied by pass 1's completed runs.
|
|
//! * `mission_phase_evaluations` is unique per (phase, iteration) and
|
|
//! upserts.
|
|
|
|
use cm_db::repo::workspaces;
|
|
use cm_domain::{Workspace, WorkspaceId};
|
|
use sqlx::Row;
|
|
use uuid::Uuid;
|
|
|
|
async fn seed_workspace(pool: &sqlx::PgPool) -> WorkspaceId {
|
|
let ws = Workspace {
|
|
id: WorkspaceId::new(),
|
|
name: "Phase Conditions Test".into(),
|
|
plan: "team".into(),
|
|
};
|
|
workspaces::insert(pool, &ws).await.unwrap();
|
|
ws.id
|
|
}
|
|
|
|
async fn seed_mission(pool: &sqlx::PgPool, ws: WorkspaceId) -> Uuid {
|
|
let id = Uuid::now_v7();
|
|
sqlx::query(
|
|
"INSERT INTO missions (id, workspace_id, title, template_kind, status)
|
|
VALUES ($1, $2, 'test mission', 'research_only', 'running')",
|
|
)
|
|
.bind(id)
|
|
.bind(ws.as_uuid())
|
|
.execute(pool)
|
|
.await
|
|
.unwrap();
|
|
id
|
|
}
|
|
|
|
/// A phase in `running`, optionally carrying a completion condition.
|
|
async fn seed_phase(
|
|
pool: &sqlx::PgPool,
|
|
mission_id: Uuid,
|
|
done_when: Option<&str>,
|
|
max_iterations: i32,
|
|
iteration: i32,
|
|
) -> Uuid {
|
|
let id = Uuid::now_v7();
|
|
sqlx::query(
|
|
"INSERT INTO mission_phases
|
|
(id, mission_id, kind, order_idx, status, done_when, max_iterations, iteration)
|
|
VALUES ($1, $2, 'research', 0, 'running', $3, $4, $5)",
|
|
)
|
|
.bind(id)
|
|
.bind(mission_id)
|
|
.bind(done_when)
|
|
.bind(max_iterations)
|
|
.bind(iteration)
|
|
.execute(pool)
|
|
.await
|
|
.unwrap();
|
|
id
|
|
}
|
|
|
|
async fn seed_run(
|
|
pool: &sqlx::PgPool,
|
|
ws: WorkspaceId,
|
|
mission_id: Uuid,
|
|
phase_id: Uuid,
|
|
status: &str,
|
|
iteration: i32,
|
|
) {
|
|
sqlx::query(
|
|
"INSERT INTO topology_runs
|
|
(id, workspace_id, task, kind, status, tier, mission_id, mission_phase_id, iteration)
|
|
VALUES ($1, $2, 'task', 'run', $3, 'team', $4, $5, $6)",
|
|
)
|
|
.bind(Uuid::now_v7())
|
|
.bind(ws.as_uuid())
|
|
.bind(status)
|
|
.bind(mission_id)
|
|
.bind(phase_id)
|
|
.bind(iteration)
|
|
.execute(pool)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
/// The exact statement `phase_runner::close_finished_phases` runs.
|
|
async fn close_finished_phases(pool: &sqlx::PgPool) {
|
|
sqlx::query(
|
|
"UPDATE mission_phases mp
|
|
SET status =
|
|
CASE
|
|
WHEN EXISTS (
|
|
SELECT 1 FROM topology_runs r
|
|
WHERE r.mission_phase_id = mp.id
|
|
AND r.iteration = mp.iteration
|
|
AND r.status = 'failed'
|
|
) THEN 'failed'
|
|
WHEN mp.done_when IS NOT NULL AND btrim(mp.done_when) <> '' THEN 'evaluating'
|
|
ELSE 'completed'
|
|
END,
|
|
completed_at =
|
|
CASE
|
|
WHEN mp.done_when IS NOT NULL AND btrim(mp.done_when) <> ''
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM topology_runs r
|
|
WHERE r.mission_phase_id = mp.id
|
|
AND r.iteration = mp.iteration
|
|
AND r.status = 'failed'
|
|
)
|
|
THEN NULL ELSE now()
|
|
END
|
|
WHERE mp.status = 'running'
|
|
AND EXISTS (
|
|
SELECT 1 FROM topology_runs r
|
|
WHERE r.mission_phase_id = mp.id AND r.iteration = mp.iteration
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM topology_runs r
|
|
WHERE r.mission_phase_id = mp.id
|
|
AND r.iteration = mp.iteration
|
|
AND r.status NOT IN ('completed', 'failed', 'cancelled')
|
|
)",
|
|
)
|
|
.execute(pool)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
async fn phase_status(pool: &sqlx::PgPool, phase_id: Uuid) -> String {
|
|
sqlx::query("SELECT status FROM mission_phases WHERE id = $1")
|
|
.bind(phase_id)
|
|
.fetch_one(pool)
|
|
.await
|
|
.unwrap()
|
|
.get::<String, _>("status")
|
|
}
|
|
|
|
/// The regression guard. A mission that never opts into a condition must
|
|
/// behave exactly as it did before conditions existed.
|
|
#[tokio::test]
|
|
async fn phase_without_condition_completes_as_before() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let ws = seed_workspace(&pool).await;
|
|
let mission = seed_mission(&pool, ws).await;
|
|
let phase = seed_phase(&pool, mission, None, 1, 0).await;
|
|
seed_run(&pool, ws, mission, phase, "completed", 0).await;
|
|
|
|
close_finished_phases(&pool).await;
|
|
assert_eq!(phase_status(&pool, phase).await, "completed");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn phase_with_condition_goes_to_evaluating() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let ws = seed_workspace(&pool).await;
|
|
let mission = seed_mission(&pool, ws).await;
|
|
let phase = seed_phase(&pool, mission, Some("a brief exists"), 3, 0).await;
|
|
seed_run(&pool, ws, mission, phase, "completed", 0).await;
|
|
|
|
close_finished_phases(&pool).await;
|
|
assert_eq!(
|
|
phase_status(&pool, phase).await,
|
|
"evaluating",
|
|
"a phase with a condition must be judged before it can complete"
|
|
);
|
|
// completed_at must stay NULL while the phase is still being judged.
|
|
let completed_at: Option<time::OffsetDateTime> =
|
|
sqlx::query("SELECT completed_at FROM mission_phases WHERE id = $1")
|
|
.bind(phase)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap()
|
|
.get("completed_at");
|
|
assert!(completed_at.is_none(), "not finished, so not timestamped");
|
|
}
|
|
|
|
/// A blank condition is not a condition — otherwise a UI that sends "" would
|
|
/// silently park every phase in `evaluating` forever.
|
|
#[tokio::test]
|
|
async fn blank_condition_is_treated_as_none() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let ws = seed_workspace(&pool).await;
|
|
let mission = seed_mission(&pool, ws).await;
|
|
let phase = seed_phase(&pool, mission, Some(" "), 3, 0).await;
|
|
seed_run(&pool, ws, mission, phase, "completed", 0).await;
|
|
|
|
close_finished_phases(&pool).await;
|
|
assert_eq!(phase_status(&pool, phase).await, "completed");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn failed_run_fails_the_phase_even_with_a_condition() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let ws = seed_workspace(&pool).await;
|
|
let mission = seed_mission(&pool, ws).await;
|
|
let phase = seed_phase(&pool, mission, Some("a brief exists"), 3, 0).await;
|
|
seed_run(&pool, ws, mission, phase, "failed", 0).await;
|
|
|
|
close_finished_phases(&pool).await;
|
|
assert_eq!(
|
|
phase_status(&pool, phase).await,
|
|
"failed",
|
|
"there is nothing to evaluate when the work itself failed"
|
|
);
|
|
}
|
|
|
|
/// The subtle one. On pass 2 the phase has `iteration = 1`, but pass 1's
|
|
/// completed run is still in the table. Without scoping the check to the
|
|
/// current iteration, that stale row satisfies "all runs finished" and the
|
|
/// phase completes having done no work on this pass.
|
|
#[tokio::test]
|
|
async fn second_pass_is_not_satisfied_by_first_pass_runs() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let ws = seed_workspace(&pool).await;
|
|
let mission = seed_mission(&pool, ws).await;
|
|
// Phase is on pass 2 (iteration=1) and running.
|
|
let phase = seed_phase(&pool, mission, Some("a brief exists"), 3, 1).await;
|
|
// Pass 1 left a completed run behind.
|
|
seed_run(&pool, ws, mission, phase, "completed", 0).await;
|
|
// Pass 2's run is still queued.
|
|
seed_run(&pool, ws, mission, phase, "queued", 1).await;
|
|
|
|
close_finished_phases(&pool).await;
|
|
assert_eq!(
|
|
phase_status(&pool, phase).await,
|
|
"running",
|
|
"pass 1's completed run must not close out pass 2"
|
|
);
|
|
|
|
// Finish pass 2 for real.
|
|
sqlx::query(
|
|
"UPDATE topology_runs SET status = 'completed'
|
|
WHERE mission_phase_id = $1 AND iteration = 1",
|
|
)
|
|
.bind(phase)
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
close_finished_phases(&pool).await;
|
|
assert_eq!(phase_status(&pool, phase).await, "evaluating");
|
|
}
|
|
|
|
/// A phase whose current pass has enqueued nothing yet must not be closed by
|
|
/// an earlier pass's rows either.
|
|
#[tokio::test]
|
|
async fn phase_with_no_runs_this_pass_stays_running() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let ws = seed_workspace(&pool).await;
|
|
let mission = seed_mission(&pool, ws).await;
|
|
let phase = seed_phase(&pool, mission, None, 3, 1).await;
|
|
seed_run(&pool, ws, mission, phase, "completed", 0).await;
|
|
|
|
close_finished_phases(&pool).await;
|
|
assert_eq!(phase_status(&pool, phase).await, "running");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn evaluations_are_unique_per_iteration_and_upsert() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let ws = seed_workspace(&pool).await;
|
|
let mission = seed_mission(&pool, ws).await;
|
|
let phase = seed_phase(&pool, mission, Some("done"), 3, 0).await;
|
|
|
|
let first = cm_api::evaluator::Verdict {
|
|
met: false,
|
|
reason: "no brief yet".into(),
|
|
guidance: "no brief yet".into(),
|
|
model: "runtime:coordinator".into(),
|
|
error: None,
|
|
checks: Vec::new(),
|
|
independent: false,
|
|
};
|
|
cm_api::evaluator::record(&pool, mission, phase, 0, &first)
|
|
.await
|
|
.unwrap();
|
|
// Same iteration again — upsert, not a duplicate row or a constraint error.
|
|
let second = cm_api::evaluator::Verdict {
|
|
met: true,
|
|
reason: "brief written".into(),
|
|
guidance: String::new(),
|
|
model: "runtime:coordinator".into(),
|
|
error: None,
|
|
checks: vec![cm_api::evaluator_tools::CheckOutcome {
|
|
argv: vec!["cargo".into(), "test".into()],
|
|
ran: true,
|
|
refused: false,
|
|
exit_code: Some(0),
|
|
evidence: "exit status: 0".into(),
|
|
}],
|
|
independent: false,
|
|
};
|
|
cm_api::evaluator::record(&pool, mission, phase, 0, &second)
|
|
.await
|
|
.unwrap();
|
|
|
|
let count: i64 =
|
|
sqlx::query("SELECT count(*) AS n FROM mission_phase_evaluations WHERE phase_id = $1")
|
|
.bind(phase)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap()
|
|
.get("n");
|
|
assert_eq!(count, 1, "one row per (phase, iteration)");
|
|
|
|
// The upsert replaced the verdict: met flipped false -> true, and the
|
|
// guidance went empty, which is what a met verdict carries (there is no
|
|
// next pass to brief).
|
|
let latest = cm_api::evaluator::latest(&pool, phase).await.unwrap();
|
|
assert_eq!(latest, Some((0, true, String::new())));
|
|
|
|
// The operator-facing reason is still stored in full — it is only the
|
|
// agent-facing half that is allowed to be empty here.
|
|
let reason: String =
|
|
sqlx::query("SELECT reason FROM mission_phase_evaluations WHERE phase_id = $1")
|
|
.bind(phase)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap()
|
|
.get("reason");
|
|
assert_eq!(reason, "brief written");
|
|
}
|
|
|
|
/// `latest` must return the newest pass, which is what feeds guidance into the
|
|
/// next attempt.
|
|
#[tokio::test]
|
|
async fn latest_returns_the_most_recent_iteration() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let ws = seed_workspace(&pool).await;
|
|
let mission = seed_mission(&pool, ws).await;
|
|
let phase = seed_phase(&pool, mission, Some("done"), 3, 0).await;
|
|
|
|
for (i, reason) in [(0, "first"), (1, "second"), (2, "third")] {
|
|
cm_api::evaluator::record(
|
|
&pool,
|
|
mission,
|
|
phase,
|
|
i,
|
|
&cm_api::evaluator::Verdict {
|
|
met: false,
|
|
reason: reason.into(),
|
|
// `latest` must return the agent-facing guidance, never the
|
|
// operator-facing reason — the two are deliberately different
|
|
// here so a regression to `reason` fails this test.
|
|
guidance: format!("{reason}-guidance"),
|
|
model: "m".into(),
|
|
error: None,
|
|
checks: Vec::new(),
|
|
independent: false,
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
let latest = cm_api::evaluator::latest(&pool, phase).await.unwrap();
|
|
assert_eq!(latest, Some((2, false, "third-guidance".into())));
|
|
}
|