vm_tool_gate writes denied.jsonl for every call it refuses and an `inert` marker each time it cannot parse its input and lets the call through. The guest has written both since the gate existed; nothing read them out of a VM. A denial, or a gate that had quietly stopped checking, left no trace — the same shape the container tier closed with drain_inert on 09-14. The executor probes both files (one exec, while /root still exists) into VmOutcome.tool_gate; launch_microvm_phase records them on the mission as the container tier's `gate.inert` (with the count) and `gate.denied` (one event per refused call, the gate's own JSON as the detail). Absent gate is None, not zero — "no gate" and "a gate that refused nothing" are different facts. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
3302 lines
144 KiB
Rust
3302 lines
144 KiB
Rust
//! Mission phase execution runner.
|
||
//!
|
||
//! Scans `mission_phases` where status='pending' AND the parent
|
||
//! mission is 'running'. Only advances a phase when all lower-order
|
||
//! phases have completed — enforces the research → coding → benchmark
|
||
//! sequence encoded in `mission_phases.order_idx`.
|
||
//!
|
||
//! For each eligible phase, enqueues one `topology_runs` row per team
|
||
//! whose (mission_id, purpose) matches the phase kind:
|
||
//!
|
||
//! phase kind='research' → teams with purpose='research'
|
||
//! phase kind='coding' → teams with purpose='coding'
|
||
//! phase kind='benchmark' → teams with purpose='coding' (fallback)
|
||
//! phase kind='security_scan' → teams with purpose='security' or 'coding'
|
||
//!
|
||
//! Each run's task text combines the mission description + a phase-
|
||
//! kind-specific directive so the coordinator claw knows what to do.
|
||
//! The topology_worker (existing) picks up queued runs and drives
|
||
//! them through the ZeroClaw executor.
|
||
//!
|
||
//! Post-completion: when every topology_run bound to a phase is
|
||
//! terminal, the phase flips to 'completed' (or 'failed' if any run
|
||
//! failed). When every phase is terminal, the mission flips to
|
||
//! 'completed' (or 'failed').
|
||
//!
|
||
//! Cadence: 10s poll. Deliberately generous — every state transition
|
||
//! is idempotent and cheap.
|
||
|
||
use sqlx::PgPool;
|
||
use sqlx::Row;
|
||
use std::time::Duration;
|
||
use uuid::Uuid;
|
||
|
||
const POLL_INTERVAL: Duration = Duration::from_secs(10);
|
||
|
||
/// `runtime` is needed only by the completion evaluator; phases without a
|
||
/// `done_when` never touch it. `hub` is needed only by microVM missions, which
|
||
/// execute on a fleet node rather than in a container here.
|
||
pub fn spawn(
|
||
pool: PgPool,
|
||
runtime: cm_runtime::Runtime,
|
||
hub: std::sync::Arc<crate::fleet::NodeHub>,
|
||
) {
|
||
tokio::spawn(async move {
|
||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||
let mut ticker = tokio::time::interval(POLL_INTERVAL);
|
||
ticker.tick().await;
|
||
loop {
|
||
ticker.tick().await;
|
||
if let Err(e) = sweep_once(&pool, &runtime, &hub).await {
|
||
eprintln!("phase_runner: sweep failed: {e}");
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
async fn sweep_once(
|
||
pool: &PgPool,
|
||
runtime: &cm_runtime::Runtime,
|
||
hub: &std::sync::Arc<crate::fleet::NodeHub>,
|
||
) -> Result<(), String> {
|
||
start_pending_phases(pool, hub).await?;
|
||
close_finished_phases(pool).await?;
|
||
// Between "all runs finished" and "phase done" sits the completion
|
||
// evaluation, for phases that declare a condition.
|
||
evaluate_finished_phases(pool, runtime).await?;
|
||
// Capture before the mission closes and long before the sweeper reaps the
|
||
// checkout. Idempotent, so a failure here is retried on the next tick
|
||
// rather than losing the phase's work.
|
||
capture_finished_coding_phases(pool).await?;
|
||
// The same, for missions with no repository to diff. Without this the
|
||
// container holding a research phase's only output is reaped unread.
|
||
crate::mission_outputs::capture_repo_less_phases(pool).await?;
|
||
// Benchmark phases record their baseline once the work exists to measure.
|
||
baseline_finished_benchmark_phases(pool).await?;
|
||
// Security phases run the scanners once the checkout exists to scan.
|
||
scan_finished_security_phases(pool).await?;
|
||
// Container-tier phases leave their tool calls in the container's tap;
|
||
// nothing else comes to collect them.
|
||
drain_finished_container_phases(pool).await?;
|
||
// A failed phase makes every later phase unreachable, and saying so is what
|
||
// lets the mission finish at all.
|
||
skip_unreachable_phases(pool).await?;
|
||
close_finished_missions(pool).await?;
|
||
Ok(())
|
||
}
|
||
|
||
/// How many phases to capture per tick. Capture shells out to git against a
|
||
/// working tree, so a backlog should be worked through steadily rather than
|
||
/// all at once.
|
||
const CAPTURE_BATCH: i64 = 5;
|
||
|
||
/// Write out the diff for any finished phase of a mission that has a repo.
|
||
///
|
||
/// Not just coding phases. `phase_task_text` tells a *research* phase to
|
||
/// "save findings under /mission/repo/research/ using file_edit", so research
|
||
/// output is real work sitting in the checkout, and the checkout is deleted
|
||
/// thirty minutes after the mission ends. Filtering to coding kinds would have
|
||
/// quietly thrown away every research brief a repo-bearing mission produced.
|
||
///
|
||
/// One consequence to know about: `git diff HEAD` is cumulative, so in a
|
||
/// research→coding mission the coding phase's patch also contains the research
|
||
/// phase's files. That resolves itself once each phase commits — the next
|
||
/// phase then diffs against the previous phase's commit rather than the
|
||
/// original HEAD.
|
||
///
|
||
/// Deliberately not hung off `close_finished_phases` or
|
||
/// `evaluate_finished_phases`: a phase reaches `completed` through either
|
||
/// path depending on whether it declared a `done_when`, and bolting capture
|
||
/// onto one of them would silently skip the other. Driving it from the sweep
|
||
/// with a `NOT EXISTS` guard covers both and is retryable by construction.
|
||
async fn capture_finished_coding_phases(pool: &PgPool) -> Result<(), String> {
|
||
let rows = sqlx::query(
|
||
"SELECT mp.id, mp.mission_id, mp.kind, mp.config, m.runtime_kind
|
||
FROM mission_phases mp
|
||
JOIN missions m ON m.id = mp.mission_id
|
||
-- Terminal, not successful. A phase that did the work and missed its
|
||
-- goal condition still produced a diff, and that diff is what an
|
||
-- operator needs in order to see WHY it missed and what the next pass
|
||
-- can build on. Capturing only completed phases meant that the moment
|
||
-- an unmet phase began reporting failed — correctly — its work was
|
||
-- silently discarded. What was produced, and whether the goal was met,
|
||
-- are different facts: the artifact records the first and mp.status
|
||
-- the second.
|
||
WHERE mp.status IN ('completed', 'failed')
|
||
AND m.repo_id IS NOT NULL
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM mission_artifacts a
|
||
WHERE a.mission_id = mp.mission_id
|
||
AND a.phase_id = mp.id
|
||
AND a.kind = 'code_diff'
|
||
)
|
||
ORDER BY mp.completed_at DESC NULLS LAST
|
||
LIMIT $1",
|
||
)
|
||
.bind(CAPTURE_BATCH)
|
||
.fetch_all(pool)
|
||
.await
|
||
.map_err(|e| format!("select phases to capture: {e}"))?;
|
||
|
||
for row in rows {
|
||
use sqlx::Row;
|
||
let phase_id: Uuid = row.get("id");
|
||
let mission_id: Uuid = row.get("mission_id");
|
||
let kind: String = row.get("kind");
|
||
let config: serde_json::Value = row.get("config");
|
||
let runtime_kind: String = row.get("runtime_kind");
|
||
// Pull the agent's work back onto the host before capturing it.
|
||
// Unpacks over the same checkout path, so capture below is unchanged.
|
||
//
|
||
// NOT for a microVM mission: it has no container, so this would fail to
|
||
// connect and `continue` — skipping capture forever and delivering
|
||
// nothing, while the phase sat there marked completed. Its work was
|
||
// already collected out of the VM by `microvm_executor`, over the same
|
||
// host path, before the VM was destroyed.
|
||
if crate::mission_fs::copy_mode() && runtime_kind != "microvm" {
|
||
let container = crate::mission_runtime::container_name(mission_id);
|
||
if let Err(e) = crate::mission_fs::sync_out(&container, mission_id).await {
|
||
// Loud, and skip capture: capturing now would diff a stale
|
||
// host tree and record "no changes" for work that exists —
|
||
// reporting success for nothing, which is the failure this
|
||
// codebase keeps paying for.
|
||
eprintln!(
|
||
"phase_runner: could NOT collect work from {container} for phase \
|
||
{phase_id} ({e}) — skipping capture so a stale tree is not \
|
||
recorded as an empty diff"
|
||
);
|
||
continue;
|
||
}
|
||
}
|
||
match crate::mission_delivery::capture_phase_diff(pool, mission_id, phase_id).await {
|
||
Ok(Some(c)) => {
|
||
// Capture is the first moment the platform knows whether the
|
||
// phase produced anything — it runs after the phase is already
|
||
// `completed`, because capture selects on that status. So the
|
||
// verdict is applied here rather than at completion.
|
||
if empty_delivery_is_a_failure(
|
||
&kind,
|
||
c.files_changed,
|
||
c.diff_error.as_deref(),
|
||
&config,
|
||
) {
|
||
eprintln!(
|
||
"phase_runner: phase {phase_id} ({kind}) of mission {mission_id} \
|
||
delivered NO files — failing it. Set config.allow_empty = true if \
|
||
this phase is meant to verify rather than change."
|
||
);
|
||
if let Err(e) =
|
||
sqlx::query("UPDATE mission_phases SET status = 'failed' WHERE id = $1")
|
||
.bind(phase_id)
|
||
.execute(pool)
|
||
.await
|
||
{
|
||
eprintln!("phase_runner: failing empty phase {phase_id}: {e}");
|
||
}
|
||
}
|
||
}
|
||
Ok(None) => {
|
||
// The checkout is gone — reaped before capture reached this
|
||
// phase. Record that, or the row stays eligible forever; and
|
||
// because the batch is bounded, a handful of dead phases
|
||
// occupy every slot permanently and no live mission is ever
|
||
// captured again. That is exactly how this was found: five
|
||
// reaped phases from earlier runs blocked the batch while a
|
||
// freshly finished coding phase went untouched.
|
||
if let Err(e) =
|
||
crate::mission_delivery::record_uncapturable(pool, mission_id, phase_id).await
|
||
{
|
||
eprintln!("phase_runner: recording uncapturable phase {phase_id}: {e}");
|
||
}
|
||
}
|
||
Err(e) => {
|
||
// A real failure against a checkout that still exists; the
|
||
// next tick retries it.
|
||
eprintln!(
|
||
"phase_runner: capturing diff for mission {mission_id} phase {phase_id}: {e}"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// A repo-less mission must not be told its scratch directory is a git checkout.
|
||
///
|
||
/// The eight ClawHDF5 research documents were written because the preamble said
|
||
/// "/mission/repo ... is the mission's git checkout" to a mission that had none.
|
||
/// One agent recorded the contradiction verbatim — "No git repo — file is
|
||
/// written" — and wrote into a container that was then reaped unread.
|
||
#[cfg(test)]
|
||
mod skill_delivery_wiring_tests {
|
||
/// Every tier without per-turn injection must be handed the task text that
|
||
/// CARRIES the skills.
|
||
///
|
||
/// The behavioural tests in `tests/mission_skill_delivery.rs` prove
|
||
/// `phase_skills_text` and `compose_turn_prompt` work. They cannot prove
|
||
/// the three `launch_*` calls pass the composed string rather than the bare
|
||
/// one — and that substitution is a one-word edit that would silently
|
||
/// return all three tiers to delivering no skill, with every test still
|
||
/// green. Same reasoning as `mission_events::the_cap_is_enforced_in_one_statement`.
|
||
/// The prompt must be recorded by the tier that SENDS it.
|
||
///
|
||
/// Recording at the dispatch fork wrote a phase prompt for container
|
||
/// missions too, and the container tier does not send that text — it sends
|
||
/// the bare task and appends skills per turn. Observed on a live mission:
|
||
/// three prompt.composed rows, one of which was never given to anything.
|
||
#[test]
|
||
fn every_solo_tier_records_the_prompt_it_actually_sends() {
|
||
let src = include_str!("phase_runner.rs");
|
||
let anchor = format!("async fn {}(", "record_phase_prompt");
|
||
assert!(
|
||
src.contains(&anchor),
|
||
"the per-tier recorder is gone; a fork-level record would log \
|
||
prompts that were never sent"
|
||
);
|
||
for launcher in [
|
||
"launch_composed_microvm_phase",
|
||
"launch_microvm_phase",
|
||
"launch_direct_session",
|
||
] {
|
||
let body = src
|
||
.split(&format!("async fn {launcher}("))
|
||
.nth(1)
|
||
.and_then(|s| s.split("\nasync fn ").next())
|
||
.unwrap_or_else(|| panic!("{launcher} not found"));
|
||
assert!(
|
||
body.contains("record_phase_prompt("),
|
||
"{launcher} runs a prompt it never records — that phase becomes \
|
||
unexplainable after the fact"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn the_three_solo_tiers_are_handed_the_skill_bearing_task() {
|
||
let src = include_str!("phase_runner.rs");
|
||
// Only the dispatch body, so the helper definitions and these tests do
|
||
// not satisfy the assertion by accident.
|
||
// Built at runtime, not written as one literal: a literal anchor
|
||
// appears in THIS test's own source too, and the split then matches
|
||
// itself instead of the code under test.
|
||
let anchor = format!("let task_with_skills = match {}(", "phase_skills_text");
|
||
let body = src
|
||
.split(&anchor)
|
||
.nth(1)
|
||
.and_then(|s| s.split("\nasync fn ").next())
|
||
.expect("dispatch body");
|
||
|
||
for (call, tier) in [
|
||
("launch_composed_microvm_phase(", "composed microVM"),
|
||
("launch_microvm_phase(", "solo microVM"),
|
||
("launch_direct_session(", "direct session"),
|
||
] {
|
||
let args = body
|
||
.split(call)
|
||
.nth(1)
|
||
.and_then(|s| s.split(')').next())
|
||
.unwrap_or_else(|| panic!("{tier}: call site not found"));
|
||
assert!(
|
||
args.contains("&task_with_skills"),
|
||
"{tier} is passed the bare task — that tier has no per-turn \
|
||
injection, so its agents would receive no skill at all"
|
||
);
|
||
assert!(
|
||
!args.contains("&task,"),
|
||
"{tier} is passed `&task`, the pre-skills string"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod attribution_tests {
|
||
use super::attribute_sessions;
|
||
use crate::vm_tool_tap::Observed;
|
||
use uuid::Uuid;
|
||
|
||
fn call(session: Option<&str>) -> Observed {
|
||
Observed {
|
||
tool: "Bash".into(),
|
||
path: None,
|
||
session: session.map(str::to_string),
|
||
subagent: None,
|
||
subagent_id: None,
|
||
input: serde_json::json!({"command": "ls"}),
|
||
response: serde_json::Value::Null,
|
||
}
|
||
}
|
||
|
||
/// The ordinary case: three turns, three sessions, in order.
|
||
#[test]
|
||
fn each_session_lands_on_the_turn_that_ran_it() {
|
||
let (a, b, c) = (Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7());
|
||
let tools = [
|
||
call(Some("s1")),
|
||
call(Some("s1")),
|
||
call(Some("s2")),
|
||
call(Some("s3")),
|
||
call(Some("s2")),
|
||
];
|
||
assert_eq!(
|
||
attribute_sessions(&tools, &[a, b, c]),
|
||
vec![Some(a), Some(a), Some(b), Some(c), Some(b)],
|
||
"sessions are ordered by FIRST appearance, so a later call from an \
|
||
earlier session still belongs to that earlier turn"
|
||
);
|
||
}
|
||
|
||
/// More sessions than turns — something happened this model does not
|
||
/// describe, so it must not produce a confident answer.
|
||
#[test]
|
||
fn a_count_mismatch_attributes_nothing() {
|
||
let a = Uuid::now_v7();
|
||
let tools = [call(Some("s1")), call(Some("s2"))];
|
||
assert_eq!(
|
||
attribute_sessions(&tools, &[a]),
|
||
vec![None, None],
|
||
"a plausible-looking wrong attribution puts one agent's actions on \
|
||
another agent's record, and a person later reasons from it"
|
||
);
|
||
// And the other direction.
|
||
assert_eq!(
|
||
attribute_sessions(&[call(Some("s1"))], &[a, Uuid::now_v7()]),
|
||
vec![None]
|
||
);
|
||
}
|
||
|
||
/// One call with no session id poisons the ORDER, not just itself.
|
||
#[test]
|
||
fn a_single_missing_session_refuses_the_whole_batch() {
|
||
let (a, b) = (Uuid::now_v7(), Uuid::now_v7());
|
||
let tools = [call(Some("s1")), call(None), call(Some("s2"))];
|
||
assert_eq!(
|
||
attribute_sessions(&tools, &[a, b]),
|
||
vec![None, None, None],
|
||
"a hole shifts every later session onto the wrong turn"
|
||
);
|
||
}
|
||
|
||
/// The pre-session tap, and the microVM path that supplies no turns.
|
||
#[test]
|
||
fn no_turns_and_no_sessions_stay_unattributed() {
|
||
assert_eq!(attribute_sessions(&[call(Some("s1"))], &[]), vec![None]);
|
||
assert_eq!(attribute_sessions(&[call(None)], &[]), vec![None]);
|
||
assert!(attribute_sessions(&[], &[]).is_empty());
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod repo_less_text_tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn a_repo_less_phase_is_not_told_it_has_a_checkout() {
|
||
let with = phase_task_text("research", "T", None, None, true);
|
||
let without = phase_task_text("research", "T", None, None, false);
|
||
|
||
assert!(with.contains("mission's git checkout"), "{with}");
|
||
assert!(
|
||
!without.contains("git checkout"),
|
||
"a mission with no repo must not be promised one: {without}"
|
||
);
|
||
// And it must say what DOES happen to the files, or an agent told only
|
||
// "there is no repo" has no reason to write them to disk at all.
|
||
assert!(
|
||
without.contains("published as a mission artifact"),
|
||
"{without}"
|
||
);
|
||
assert!(without.contains("NO git repository"), "{without}");
|
||
}
|
||
|
||
/// Both variants must keep the instruction that outputs are real files.
|
||
/// That line is what stops an agent pasting its work into the reply, and it
|
||
/// is load-bearing for the capture path either way.
|
||
#[test]
|
||
fn both_variants_still_demand_files_on_disk() {
|
||
for has_repo in [true, false] {
|
||
let t = phase_task_text("research", "T", None, None, has_repo);
|
||
assert!(
|
||
t.contains("REAL files with Write/Edit"),
|
||
"has_repo={has_repo}: {t}"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// The prompt must advertise the tools the agent actually has.
|
||
///
|
||
/// Every executor ends in `claude -p`, so the names are Claude Code's.
|
||
/// Advertising ZeroClaw's names instead (`file_edit`, `content_search`) —
|
||
/// and denying Bash — is what made five agents stop and ask what environment
|
||
/// they were in rather than do the work.
|
||
#[test]
|
||
fn the_prompt_names_the_tools_the_agent_actually_has() {
|
||
let t = phase_task_text("coding", "T", None, None, true);
|
||
for real in ["Read", "Edit", "Write", "Bash", "Glob", "Grep"] {
|
||
assert!(t.contains(real), "missing {real}: {t}");
|
||
}
|
||
for absent in [
|
||
"file_edit",
|
||
"content_search",
|
||
"glob_search",
|
||
"git_operations",
|
||
] {
|
||
assert!(
|
||
!t.contains(absent),
|
||
"{absent} does not exist under claude_cli — advertising it is the bug: {t}"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// A repo-less agent must be told the workspace EXISTS. It is created by
|
||
/// `mission_fs::sync_in`; saying so is what stops the agent concluding the
|
||
/// environment is broken and refusing.
|
||
#[test]
|
||
fn a_repo_less_workspace_is_promised_to_exist() {
|
||
let t = phase_task_text("research", "T", None, None, false);
|
||
assert!(t.contains("EXISTS and is writable"), "{t}");
|
||
}
|
||
}
|
||
|
||
/// Record the baseline for benchmark phases that have finished and have none.
|
||
///
|
||
/// `benchmark_runner` has existed since Slice 7 and was reachable only from
|
||
/// `POST /api/missions/{id}/benchmark` — an operator button. So a `benchmark`
|
||
/// mission, whose recipe declares `mode = "author_and_baseline"`, authored
|
||
/// benchmarks and then recorded nothing: `benchmark_snapshots` stayed empty and
|
||
/// the canvas had nothing to render. The measuring half of "author + baseline"
|
||
/// simply never ran.
|
||
///
|
||
/// Spawned, never awaited in the sweep: `BENCH_TIMEOUT` is 30 minutes, and this
|
||
/// loop also starts phases, closes them, evaluates and captures. Blocking it on
|
||
/// a benchmark would stall every mission on the platform behind one `cargo
|
||
/// bench`.
|
||
///
|
||
/// The `NOT EXISTS` guard is what makes that safe to fire per tick: a phase with
|
||
/// a snapshot is never selected again, so a spawned run cannot be started twice
|
||
/// while the first is still going.
|
||
async fn baseline_finished_benchmark_phases(pool: &PgPool) -> Result<(), String> {
|
||
let rows = sqlx::query(
|
||
"SELECT mp.id, mp.mission_id
|
||
FROM mission_phases mp
|
||
WHERE mp.kind = 'benchmark'
|
||
AND mp.status IN ('completed', 'failed')
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM benchmark_snapshots b
|
||
WHERE b.phase_id = mp.id AND b.iteration = 0
|
||
)
|
||
ORDER BY mp.completed_at DESC NULLS LAST
|
||
LIMIT 2",
|
||
)
|
||
.fetch_all(pool)
|
||
.await
|
||
.map_err(|e| format!("select benchmark phases: {e}"))?;
|
||
|
||
for row in rows {
|
||
let phase_id: Uuid = row.get("id");
|
||
let mission_id: Uuid = row.get("mission_id");
|
||
let pool = pool.clone();
|
||
tokio::spawn(async move {
|
||
match crate::benchmark_runner::baseline(&pool, mission_id, phase_id).await {
|
||
Ok(()) => eprintln!(
|
||
"phase_runner: recorded benchmark baseline for phase {phase_id} \
|
||
of mission {mission_id}"
|
||
),
|
||
// Not a phase failure. A repo with no bench harness is a normal
|
||
// outcome, and failing the phase for it would punish a mission
|
||
// that did exactly what it was asked. Said out loud, though —
|
||
// "no baseline" must not be indistinguishable from "not tried".
|
||
Err(e) => eprintln!(
|
||
"phase_runner: no benchmark baseline for phase {phase_id} \
|
||
of mission {mission_id}: {e}"
|
||
),
|
||
}
|
||
});
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Run the scanners for `security_scan` phases that have finished without one.
|
||
///
|
||
/// The same defect the benchmark sweep above was written for, in the other
|
||
/// half of the platform. `security_scan::run` — which reads `phase.config.tools`
|
||
/// to pick scanners and upserts each finding as a `mission_task` — was
|
||
/// reachable only from `POST /api/missions/{id}/security-scan`, an operator
|
||
/// button. So `security_hardening.toml`, a workflow whose entire first phase is
|
||
/// a scan, ran an agent that was never told to scan and then never fired the
|
||
/// scanner either. The phase reported `completed` having scanned nothing, and
|
||
/// the two facts that would have exposed it — zero findings, and a green
|
||
/// phase — are exactly what a genuinely clean repository also looks like.
|
||
///
|
||
/// Guarded on the marker row rather than on the presence of findings, because
|
||
/// a clean scan writes no findings: without it, a clean phase would be
|
||
/// rescanned on every tick for as long as the mission existed.
|
||
///
|
||
/// Spawned, not awaited, for the same reason as the benchmark baseline: four
|
||
/// scanners against a large tree take minutes, and this loop also starts,
|
||
/// closes and evaluates every phase on the platform.
|
||
/// The selection half of the sweep, split out so the marker guard is testable
|
||
/// without a container: the scan itself needs Docker, the guard is the part
|
||
/// that decides whether it runs twice or never.
|
||
pub async fn unscanned_security_phases(pool: &PgPool) -> Result<Vec<(Uuid, Uuid)>, String> {
|
||
let rows = sqlx::query(
|
||
"SELECT mp.id, mp.mission_id
|
||
FROM mission_phases mp
|
||
WHERE mp.kind = 'security_scan'
|
||
AND mp.status IN ('completed', 'failed')
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM mission_tasks t
|
||
WHERE t.phase_id = mp.id AND t.external_id = $1
|
||
)
|
||
ORDER BY mp.completed_at DESC NULLS LAST
|
||
LIMIT 2",
|
||
)
|
||
.bind(crate::security_scan::SCAN_MARKER)
|
||
.fetch_all(pool)
|
||
.await
|
||
.map_err(|e| format!("select security_scan phases: {e}"))?;
|
||
Ok(rows
|
||
.into_iter()
|
||
.map(|r| (r.get("id"), r.get("mission_id")))
|
||
.collect())
|
||
}
|
||
|
||
async fn scan_finished_security_phases(pool: &PgPool) -> Result<(), String> {
|
||
for (phase_id, mission_id) in unscanned_security_phases(pool).await? {
|
||
let pool = pool.clone();
|
||
tokio::spawn(async move {
|
||
match crate::security_scan::run(&pool, mission_id, phase_id).await {
|
||
Ok(n) => eprintln!(
|
||
"phase_runner: security scan recorded {n} finding(s) for phase \
|
||
{phase_id} of mission {mission_id}"
|
||
),
|
||
// Not a phase failure, for the same reason the benchmark
|
||
// baseline is not: a repo the scanners cannot read is a real
|
||
// outcome. Said out loud, though — "no findings" must not be
|
||
// indistinguishable from "never scanned".
|
||
Err(e) => eprintln!(
|
||
"phase_runner: security scan did not run for phase {phase_id} \
|
||
of mission {mission_id}: {e}"
|
||
),
|
||
}
|
||
});
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Drain the container tier's tool tap into `mission_events`.
|
||
///
|
||
/// The microVM tier records its tools during the turn, from inside the loop
|
||
/// that is watching the VM. A container turn is driven asynchronously by
|
||
/// `topology_worker`, so there is no such loop — the tap accumulates in the
|
||
/// mission's container and something has to come and collect it.
|
||
///
|
||
/// Without this the hooks write a file nobody reads, which is the same shape
|
||
/// as the gate that is installed and inert: everything looks wired and no
|
||
/// evidence ever appears.
|
||
///
|
||
/// Idempotent by truncation, not by a marker: `drain` clears the file it read,
|
||
/// so a second pass finds nothing. That is why this can run every tick without
|
||
/// a cursor column.
|
||
async fn drain_finished_container_phases(pool: &PgPool) -> Result<(), String> {
|
||
let rows = sqlx::query(
|
||
"SELECT mp.id, mp.mission_id, m.runtime_container_name
|
||
FROM mission_phases mp
|
||
JOIN missions m ON m.id = mp.mission_id
|
||
WHERE mp.status IN ('completed', 'failed')
|
||
AND m.runtime_container_name IS NOT NULL
|
||
AND mp.completed_at > now() - interval '30 minutes'
|
||
ORDER BY mp.completed_at DESC
|
||
LIMIT 4",
|
||
)
|
||
.fetch_all(pool)
|
||
.await
|
||
.map_err(|e| format!("select container phases: {e}"))?;
|
||
|
||
for row in rows {
|
||
let phase_id: Uuid = row.get("id");
|
||
let mission_id: Uuid = row.get("mission_id");
|
||
let container: String = row.get("runtime_container_name");
|
||
|
||
// `container_exec::connect`, NOT connect_with_local_defaults: the
|
||
// server reaches Docker through a socket proxy (DOCKER_HOST), so the
|
||
// local-socket connector fails — and it failed SILENTLY here, so the
|
||
// sweep did nothing while the tap filled up and every other link in
|
||
// the chain looked correct.
|
||
let docker = match crate::container_exec::connect() {
|
||
Ok(d) => d,
|
||
Err(e) => {
|
||
eprintln!("phase_runner: cannot reach docker to drain tool taps: {e}");
|
||
return Ok(());
|
||
}
|
||
};
|
||
// The gate's own confession, before its tool calls: if it could not
|
||
// parse and allowed everything, every call drained below ran unchecked
|
||
// — and until this read existed, the marker it left saying so was seen
|
||
// by exactly one unit test and no production code.
|
||
if let Some(text) = crate::container_tool_hooks::drain_inert(&docker, &container).await {
|
||
let lines = text.lines().count();
|
||
eprintln!(
|
||
"phase_runner: the tool gate in {container} went INERT {lines} time(s) \
|
||
during phase {phase_id} — those calls were allowed unchecked"
|
||
);
|
||
crate::mission_events::record(
|
||
pool,
|
||
crate::mission_events::MissionEvent::new(
|
||
mission_id,
|
||
crate::container_tool_hooks::GATE_INERT,
|
||
)
|
||
.phase(phase_id)
|
||
.detail(serde_json::json!({ "occurrences": lines, "marker": text })),
|
||
)
|
||
.await;
|
||
}
|
||
let tools = crate::container_tool_hooks::drain(&docker, &container).await;
|
||
if tools.is_empty() {
|
||
continue;
|
||
}
|
||
// The phase's own run, so the events hang off the same row the UI
|
||
// already reads. A phase with no run row still records the tools —
|
||
// losing them because the join came up empty would be the worse trade.
|
||
let run_id: Option<Uuid> =
|
||
sqlx::query_scalar("SELECT id FROM topology_runs WHERE phase_id = $1 LIMIT 1")
|
||
.bind(phase_id)
|
||
.fetch_optional(pool)
|
||
.await
|
||
.ok()
|
||
.flatten();
|
||
// The agent of each turn, in the order the turns ran. `prompt.composed`
|
||
// is written by the tier as it sends each turn, so this IS the running
|
||
// order — not a reconstruction of it.
|
||
let turn_agents: Vec<Uuid> = sqlx::query_scalar(
|
||
"SELECT agent_id FROM mission_events
|
||
WHERE phase_id = $1 AND kind = $2 AND agent_id IS NOT NULL
|
||
ORDER BY id",
|
||
)
|
||
.bind(phase_id)
|
||
.bind(crate::mission_events::PROMPT_COMPOSED)
|
||
.fetch_all(pool)
|
||
.await
|
||
.unwrap_or_default();
|
||
record_vm_tools(
|
||
pool,
|
||
mission_id,
|
||
phase_id,
|
||
run_id.unwrap_or(phase_id),
|
||
&tools,
|
||
&turn_agents,
|
||
)
|
||
.await;
|
||
eprintln!(
|
||
"phase_runner: drained {} tool call(s) from {container} for phase {phase_id}",
|
||
tools.len()
|
||
);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Did this phase finish without delivering the work it exists to produce?
|
||
///
|
||
/// A coding phase that changes no files has done nothing, and until now that
|
||
/// was reported as `completed` — the same status as a phase that delivered a
|
||
/// tested, reviewed, pushed change. Mission `019fcf62` completed that way while
|
||
/// its agents were silently unpinned from the repo, and nothing in the platform
|
||
/// said otherwise; the failure was found by a script diffing the forge.
|
||
///
|
||
/// Three things must all hold before calling it a failure, because a false
|
||
/// positive here fails honest work:
|
||
///
|
||
/// - **The phase is one whose directive tells it to write files.** That is
|
||
/// every kind except `review`: `coding` changes the tree, `research` is told
|
||
/// to "save findings under /mission/repo/research/", `benchmark` to author
|
||
/// benchmarks "under /mission/repo/benches", and `security_scan` to file
|
||
/// findings and propose patches. A phase that produced nothing did not do
|
||
/// what it was told, whatever its kind.
|
||
///
|
||
/// This used to be `kind == "coding"` alone, with the reasoning "research
|
||
/// phases legitimately write nothing to the tree" — which contradicts the
|
||
/// research directive three modules over. The cost: `benchmark` and
|
||
/// `security_hardening` missions, whose defining phases are NOT coding, had
|
||
/// no delivery guarantee at all and reported success on an empty tree.
|
||
/// - **The diff was actually computed.** An uncomputable diff also reports
|
||
/// zero files (see `mission_delivery::untrusted_empty_reason`); treating it
|
||
/// as an empty delivery would blame the agent for a platform fault.
|
||
/// - **`allow_empty` is not set.** The escape hatch for a coding phase whose
|
||
/// job is genuinely to verify rather than to change — asserted for, not
|
||
/// assumed.
|
||
fn empty_delivery_is_a_failure(
|
||
kind: &str,
|
||
files_changed: usize,
|
||
diff_error: Option<&str>,
|
||
config: &serde_json::Value,
|
||
) -> bool {
|
||
PRODUCING_KINDS.contains(&kind)
|
||
&& files_changed == 0
|
||
&& diff_error.is_none()
|
||
&& config.get("allow_empty").and_then(|v| v.as_bool()) != Some(true)
|
||
}
|
||
|
||
/// Phase kinds whose directive instructs the agent to leave files behind.
|
||
///
|
||
/// `review` is absent on purpose: a reviewing phase that changes nothing has
|
||
/// done its job, and `vm_stop_gate::per_node` makes the same distinction for the
|
||
/// same reason. Any other kind falls through to the generic directive ("Execute
|
||
/// this mission phase according to the mission brief"), which promises no files,
|
||
/// so it is not held to producing them.
|
||
const PRODUCING_KINDS: &[&str] = &["coding", "research", "benchmark", "security_scan"];
|
||
|
||
/// How long a phase may wait for a VM slot before it is failed.
|
||
///
|
||
/// Two full turns. A fleet that genuinely frees will free within one, so this
|
||
/// only fires when nothing is coming — and a phase that waited two hours must say
|
||
/// so rather than sit `pending` forever looking like a bug.
|
||
const CAPACITY_WAIT_MAX_SECS: f64 = 2.0 * 3600.0;
|
||
|
||
/// How long a phase may wait for a judge it cannot reach before it is failed.
|
||
///
|
||
/// Not consuming a pass for an unreachable judge is right; retrying forever is
|
||
/// not, because a permanently dead validator would leave the phase `evaluating`
|
||
/// in silence — a wrong failure traded for an invisible hang. Thirty minutes is
|
||
/// many sweep ticks, so a transient outage recovers well inside it, and a real
|
||
/// one surfaces as a failure that names the transport error.
|
||
const JUDGE_WAIT_MAX_SECS: f64 = 30.0 * 60.0;
|
||
|
||
/// Longest gap between two judge attempts on the same phase.
|
||
///
|
||
/// The retry used to run on the sweep's own 10s tick, so a phase whose judge
|
||
/// was unreachable re-judged 180 times in its 30-minute window. A verdict is
|
||
/// not one request — [`crate::evaluator`] loops up to `MAX_TOOL_CALLS + 1`
|
||
/// times and resends the whole growing history each round — so that was on the
|
||
/// order of 2,000 model requests for one phase nobody could judge, and on a
|
||
/// transport error they are billed: the request was processed, only its
|
||
/// response failed to decode.
|
||
const JUDGE_RETRY_MAX_BACKOFF_SECS: f64 = 5.0 * 60.0;
|
||
|
||
/// How long to wait before the next judge attempt, given how long this phase
|
||
/// has already been blocked.
|
||
///
|
||
/// Waiting as long as we have already waited doubles the total elapsed time per
|
||
/// attempt, so the schedule is exponential without storing an attempt counter:
|
||
/// 10, 20, 40, 80, 160, 300, 300 … — about ten attempts across the same
|
||
/// 30-minute window instead of a hundred and eighty.
|
||
fn judge_backoff_secs(blocked_for: f64) -> f64 {
|
||
blocked_for.clamp(POLL_INTERVAL.as_secs_f64(), JUDGE_RETRY_MAX_BACKOFF_SECS)
|
||
}
|
||
|
||
/// A judge error that retrying cannot fix before a time the error itself names.
|
||
///
|
||
/// z.ai answers an exhausted plan with a 429 carrying code `1310` and its own
|
||
/// reset timestamp. Retrying that is not optimism, it is arithmetic: the reset
|
||
/// was two days out when this fired on 2026-09-09, and the phase spent its full
|
||
/// 30-minute window asking a question whose answer could not change. Fail
|
||
/// immediately instead, and say what is actually wrong — "quota exhausted until
|
||
/// X" sends you to the plan, where "the independent validator could not be
|
||
/// reached" sends you into the mission.
|
||
///
|
||
/// Conservative on purpose: an error that does not positively identify itself
|
||
/// as an exhausted plan is treated as retryable, because giving up on a
|
||
/// transient blip costs a phase that had done nothing wrong.
|
||
fn judge_error_is_exhausted_plan(why: &str) -> Option<String> {
|
||
if !(why.contains("Limit Exhausted") || why.contains(r#""code":"1310""#)) {
|
||
return None;
|
||
}
|
||
let until: Option<String> = why.find("reset at ").map(|i| {
|
||
why[i + "reset at ".len()..]
|
||
.chars()
|
||
.take_while(|c| *c != ']' && *c != '"')
|
||
.collect::<String>()
|
||
.trim()
|
||
.to_string()
|
||
});
|
||
Some(match until.filter(|u| !u.is_empty()) {
|
||
Some(u) => format!("the judge provider's plan limit is exhausted until {u}"),
|
||
None => "the judge provider's plan limit is exhausted".to_string(),
|
||
})
|
||
}
|
||
|
||
/// Stamp why a phase is waiting, returning how long it has waited so far.
|
||
///
|
||
/// The timestamp is set once and preserved across retries, so the wait is
|
||
/// measured from the first refusal rather than reset every 10s sweep.
|
||
async fn record_capacity_block(pool: &PgPool, phase_id: Uuid, note: &str) -> f64 {
|
||
let waited: Option<f64> = sqlx::query_scalar(
|
||
"UPDATE mission_phases
|
||
SET capacity_blocked_since = COALESCE(capacity_blocked_since, now()),
|
||
capacity_note = $2
|
||
WHERE id = $1
|
||
RETURNING EXTRACT(EPOCH FROM now() - capacity_blocked_since)::float8",
|
||
)
|
||
.bind(phase_id)
|
||
.bind(note)
|
||
.fetch_optional(pool)
|
||
.await
|
||
.ok()
|
||
.flatten();
|
||
waited.unwrap_or(0.0)
|
||
}
|
||
|
||
/// Enqueue topology_runs for every phase whose predecessors are done.
|
||
async fn start_pending_phases(
|
||
pool: &PgPool,
|
||
hub: &std::sync::Arc<crate::fleet::NodeHub>,
|
||
) -> Result<(), String> {
|
||
// Eligible = pending phase, mission running, all lower-order phases
|
||
// in this mission are 'completed'. `NOT EXISTS ... status <> completed`
|
||
// handles order 0 (no prior rows) + skipped phases naturally.
|
||
let rows = sqlx::query(
|
||
"SELECT mp.id, mp.mission_id, mp.kind, mp.order_idx, mp.iteration,
|
||
mp.config->>'task' AS phase_task,
|
||
-- The whole config, not just the task: the stop gate is built
|
||
-- from `allow_empty` and `done_when_check`, and a phase that
|
||
-- declares a completion check gets it enforced in the agent's
|
||
-- own loop rather than only after it has finished.
|
||
mp.config,
|
||
m.workspace_id, m.title, m.description,
|
||
-- Where and how this mission executes. `runtime_kind` decides
|
||
-- which executor takes the phase; without it 'microvm' is a
|
||
-- value the placement code honours and nothing reads.
|
||
m.runtime_kind, m.backend, m.target_node_id, m.team_engine,
|
||
-- Whether a checkout exists at all. A repo-less mission's
|
||
-- /mission/repo is scratch space, and the task text must say so.
|
||
(m.repo_id IS NOT NULL) AS has_repo
|
||
FROM mission_phases mp
|
||
JOIN missions m ON m.id = mp.mission_id
|
||
WHERE mp.status = 'pending'
|
||
AND m.status = 'running'
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM mission_phases prior
|
||
WHERE prior.mission_id = mp.mission_id
|
||
AND prior.order_idx < mp.order_idx
|
||
AND prior.status <> 'completed'
|
||
)
|
||
LIMIT 20",
|
||
)
|
||
.fetch_all(pool)
|
||
.await
|
||
.map_err(|e| format!("query eligible phases: {e}"))?;
|
||
|
||
for row in rows {
|
||
let phase_id: Uuid = row.get("id");
|
||
let mission_id: Uuid = row.get("mission_id");
|
||
let kind: String = row.get("kind");
|
||
let workspace_id: Uuid = row.get("workspace_id");
|
||
let title: String = row.get("title");
|
||
let description: Option<String> = row.get("description");
|
||
let phase_task: Option<String> = row.get("phase_task");
|
||
let config: serde_json::Value = row.get("config");
|
||
let iteration: i32 = row.get("iteration");
|
||
let runtime_kind: String = row.get("runtime_kind");
|
||
let backend: Option<String> = row.get("backend");
|
||
let target_node_id: Option<Uuid> = row.get("target_node_id");
|
||
let team_engine: Option<String> = row.get("team_engine");
|
||
let has_repo: bool = row.get("has_repo");
|
||
|
||
if let Err(e) = launch_phase(
|
||
pool,
|
||
hub,
|
||
PhaseLaunch {
|
||
phase_id,
|
||
mission_id,
|
||
kind: &kind,
|
||
workspace_id,
|
||
title: &title,
|
||
description: description.as_deref(),
|
||
phase_task: phase_task.as_deref(),
|
||
config: &config,
|
||
iteration,
|
||
runtime_kind: &runtime_kind,
|
||
backend: backend.as_deref(),
|
||
target_node_id,
|
||
team_engine: team_engine.as_deref(),
|
||
has_repo,
|
||
},
|
||
)
|
||
.await
|
||
{
|
||
eprintln!("phase_runner: launch phase {phase_id} failed: {e}");
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Everything `launch_phase` needs about the phase it is starting, gathered
|
||
/// from the eligibility query.
|
||
struct PhaseLaunch<'a> {
|
||
phase_id: Uuid,
|
||
mission_id: Uuid,
|
||
kind: &'a str,
|
||
workspace_id: Uuid,
|
||
title: &'a str,
|
||
description: Option<&'a str>,
|
||
/// This phase's own instructions, from `mission_phases.config.task`.
|
||
///
|
||
/// Without it every phase of a mission receives byte-identical task text
|
||
/// and differs only by the kind directive — so a two-phase mission has
|
||
/// both phases do the same work. Mission `019fc42b` demonstrated it: two
|
||
/// coding phases with distinct `task` values both produced the same two
|
||
/// files, because neither phase ever saw its own instructions.
|
||
phase_task: Option<&'a str>,
|
||
/// `mission_phases.config`, for the settings that shape execution rather
|
||
/// than describe the work — currently the stop gate's two.
|
||
config: &'a serde_json::Value,
|
||
/// Which pass this is, 0-based. Stamped onto the runs so the completion
|
||
/// check can tell this pass's work from the previous one's.
|
||
iteration: i32,
|
||
/// `missions.runtime_kind`. Selects the executor.
|
||
runtime_kind: &'a str,
|
||
/// `missions.backend` — which per-CLI image, on the microVM path.
|
||
backend: Option<&'a str>,
|
||
/// Set by `mission_orchestrator` at launch. On the microVM path it is where
|
||
/// the VM boots, and it is not optional there.
|
||
target_node_id: Option<Uuid>,
|
||
/// `missions.team_engine`. NULL = solo.
|
||
team_engine: Option<&'a str>,
|
||
/// Whether `missions.repo_id` is set. Decides whether the agents are told
|
||
/// `/mission/repo` is a git checkout or a scratch workspace whose contents
|
||
/// are captured as artifacts.
|
||
has_repo: bool,
|
||
}
|
||
|
||
/// Which team purposes execute a phase of this kind.
|
||
///
|
||
/// `pub(crate)` on purpose: the World visualization attributes agents to phase
|
||
/// orbs, and it must use the SAME mapping the runner uses to pick executors. A
|
||
/// second copy would let the picture disagree with the machine about who is
|
||
/// working on what — which presents as a rendering bug and is really a lie.
|
||
pub(crate) fn purposes_for(kind: &str) -> &'static [&'static str] {
|
||
match kind {
|
||
"research" => &["research", "mission"],
|
||
"coding" => &["coding", "mission"],
|
||
"benchmark" => &["coding", "mission"],
|
||
"security_scan" => &["security", "coding", "mission"],
|
||
_ => &["mission"],
|
||
}
|
||
}
|
||
|
||
async fn launch_phase(
|
||
pool: &PgPool,
|
||
hub: &std::sync::Arc<crate::fleet::NodeHub>,
|
||
p: PhaseLaunch<'_>,
|
||
) -> Result<(), String> {
|
||
let PhaseLaunch {
|
||
phase_id,
|
||
mission_id,
|
||
kind,
|
||
workspace_id,
|
||
title,
|
||
description,
|
||
phase_task,
|
||
has_repo,
|
||
iteration,
|
||
// Destructured but read through `p` below, so the compiler keeps this
|
||
// pattern honest if a field is added.
|
||
config: _,
|
||
runtime_kind: _,
|
||
backend: _,
|
||
target_node_id: _,
|
||
team_engine: _,
|
||
} = p;
|
||
// Which team purposes should execute this phase.
|
||
let purposes: &[&str] = purposes_for(kind);
|
||
|
||
// Load teams for this mission matching any of the purposes.
|
||
let team_rows = sqlx::query(
|
||
"SELECT mt.team_id, t.graph
|
||
FROM mission_teams mt
|
||
JOIN teams t ON t.id = mt.team_id
|
||
WHERE mt.mission_id = $1
|
||
AND mt.purpose = ANY($2)",
|
||
)
|
||
.bind(mission_id)
|
||
.bind(purposes)
|
||
.fetch_all(pool)
|
||
.await
|
||
.map_err(|e| format!("query mission teams: {e}"))?;
|
||
|
||
// A microVM mission has no teams and needs none: its agent is a `claude -p`
|
||
// inside a VM, not a graph of ZeroClaw claws. Without this exemption the
|
||
// phase would sit `pending` forever while the log said only "no matching
|
||
// teams" — the executor below would never be reached at all.
|
||
//
|
||
// The checkout below still runs, because the VM needs the repository.
|
||
if team_rows.is_empty() && p.runtime_kind != "microvm" {
|
||
eprintln!(
|
||
"phase_runner: mission {mission_id} phase {phase_id} ({kind}) has no matching teams — skipping (staying pending)"
|
||
);
|
||
return Ok(());
|
||
}
|
||
|
||
// Ensure the mission's repo is checked out first — the runtime
|
||
// container bind-mounts /var/lib/clawmates-missions/{id}, which
|
||
// must exist before docker start or the mount fails.
|
||
// Idempotent: fetch+reset on existing clones, clone on missing.
|
||
// Non-fatal: research-only missions have no repo and skip cleanly.
|
||
match crate::mission_workspace::ensure_checkout(
|
||
pool,
|
||
cm_domain::WorkspaceId::from(workspace_id),
|
||
mission_id,
|
||
)
|
||
.await
|
||
{
|
||
Ok(Some(path)) => {
|
||
eprintln!(
|
||
"phase_runner: repo checked out at {} for mission {mission_id} phase {phase_id}",
|
||
path.display()
|
||
);
|
||
// From here the checkout belongs to a running phase. Recording it
|
||
// explicitly is what stops the *next* phase's launch from
|
||
// refreshing the tree out from under this one's output — a
|
||
// decision that must not depend on what the agent leaves behind.
|
||
crate::mission_workspace::mark_phase_started(&path);
|
||
}
|
||
Ok(None) => {}
|
||
Err(e) => eprintln!(
|
||
"phase_runner: repo checkout for mission {mission_id} phase {phase_id} failed (continuing): {e}"
|
||
),
|
||
}
|
||
|
||
// Provision the per-mission runtime container if not bound.
|
||
// Idempotent — on_launch sets this on initial launch, but pre-C3
|
||
// missions or retries against a torn-down container land here.
|
||
// Always call ensure_container — the fast path re-mints a fresh
|
||
// one-time pairing code even for existing containers. Old codes
|
||
// expire / are single-use, so a launch that reuses a container
|
||
// still needs a fresh code for the topology_worker's next /pair.
|
||
//
|
||
// Skipped for a microVM mission: its agent runs in a VM on a fleet node, so
|
||
// a container here would be provisioned, have the checkout copied into it,
|
||
// and then sit idle for the life of the mission — while the pairing code and
|
||
// runtime binding it writes describe a runtime nothing is using.
|
||
let needs_container = p.runtime_kind != "microvm";
|
||
if let Some(prov) =
|
||
crate::mission_runtime::MissionRuntimeProvisioner::from_env().filter(|_| needs_container)
|
||
{
|
||
match prov.ensure_container(mission_id).await {
|
||
Ok(ec) => {
|
||
let name = crate::mission_runtime::container_name(mission_id);
|
||
crate::container_tool_hooks::record_install(
|
||
pool,
|
||
mission_id,
|
||
Some(phase_id),
|
||
ec.hooks.as_deref(),
|
||
)
|
||
.await;
|
||
// Push the checkout into the container. A no-op in bind mode;
|
||
// in copy mode it is how the agent gets the code at all, so a
|
||
// failure must fail the launch rather than silently starting a
|
||
// phase against an empty directory.
|
||
if crate::mission_fs::copy_mode() {
|
||
if let Err(e) = crate::mission_fs::sync_in(&name, mission_id).await {
|
||
return Err(format!("copy checkout into {name}: {e}"));
|
||
}
|
||
}
|
||
// Re-assert this mission's crew in the runtime config.
|
||
//
|
||
// Claws are provisioned once, at on_launch. `ensure_container`
|
||
// RECREATES a container that is not running, and recreation
|
||
// reseeds `.zeroclaw` from the seed directory — which does not
|
||
// contain this mission's claws. The agents still exist in
|
||
// Postgres, so nothing looks wrong, but the alias the turn
|
||
// dials is gone from the daemon and `/ws/chat` answers
|
||
// `400 Bad Request`. That is what a retry of mission 01a00538
|
||
// hit after its container was recreated.
|
||
//
|
||
// provision_claw is idempotent (created:false when present), so
|
||
// this costs one call per claw on the happy path and is the
|
||
// difference between a resumable mission and a dead one.
|
||
// Aim at THIS MISSION's daemon, never the global gateway. Each
|
||
// mission's turns run against its own container, which loads
|
||
// config at boot and never re-reads the file, so provisioning
|
||
// against `from_env()` writes the claws into the shared runtime
|
||
// and leaves this one with none — the failure mode
|
||
// `RuntimeProvisioner::for_gateway`'s doc comment describes, and
|
||
// the one this block exists to repair.
|
||
if let Some(rp) =
|
||
crate::runtime_provision::RuntimeProvisioner::for_gateway(ec.endpoint.clone())
|
||
{
|
||
let crew = sqlx::query(
|
||
"SELECT DISTINCT a.id, a.model_binding, t.risk_profile, t.mcp_bundles
|
||
FROM team_members tm
|
||
JOIN mission_teams mt ON mt.team_id = tm.team_id
|
||
JOIN teams t ON t.id = tm.team_id
|
||
JOIN agents a ON a.id = tm.claw_id
|
||
WHERE mt.mission_id = $1 AND a.deleted_at IS NULL",
|
||
)
|
||
.bind(mission_id)
|
||
.fetch_all(pool)
|
||
.await
|
||
.unwrap_or_default();
|
||
let mut reasserted: Vec<cm_domain::AgentId> = Vec::new();
|
||
for row in &crew {
|
||
let aid: uuid::Uuid = row.get("id");
|
||
let model: Option<String> = row.get("model_binding");
|
||
let risk: Option<String> = row.get("risk_profile");
|
||
// Re-assert the team's OWN bundles. Passing a constant
|
||
// here would quietly strip `clawmates_skills` from a
|
||
// crew that had it, and a re-provision that removes a
|
||
// capability is worse than one that never ran: the
|
||
// agent keeps working and simply stops being able to
|
||
// read its skills, halfway through the mission.
|
||
let bundles: Vec<String> = row
|
||
.get::<serde_json::Value, _>("mcp_bundles")
|
||
.as_array()
|
||
.map(|a| {
|
||
a.iter()
|
||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||
.collect()
|
||
})
|
||
.unwrap_or_default();
|
||
match rp
|
||
.provision_claw(
|
||
aid,
|
||
model.as_deref().unwrap_or("claude"),
|
||
risk.as_deref().unwrap_or("research_readonly"),
|
||
&bundles,
|
||
)
|
||
.await
|
||
{
|
||
Ok(_) => reasserted.push(cm_domain::AgentId::from(aid)),
|
||
Err(e) => eprintln!(
|
||
"phase_runner: re-provision claw {aid} for {mission_id} failed \
|
||
(continuing): {e}"
|
||
),
|
||
}
|
||
}
|
||
// Provisioning creates the agent; it does NOT set
|
||
// workspace.path (the prop-schema cannot). Without this the
|
||
// claws exist and every one of them runs in its own sandbox
|
||
// instead of the mission tree.
|
||
if let Err(e) = prov
|
||
.pin_agent_workspaces(mission_id, &reasserted, "/mission/repo")
|
||
.await
|
||
{
|
||
return Err(format!("re-pin mission workspaces: {e}"));
|
||
}
|
||
}
|
||
if let Err(e) = cm_db::repo::missions::set_runtime_binding(
|
||
pool,
|
||
mission_id,
|
||
workspace_id,
|
||
Some(&name),
|
||
Some(&ec.endpoint),
|
||
ec.pairing_code.as_deref(),
|
||
)
|
||
.await
|
||
{
|
||
eprintln!(
|
||
"phase_runner: bind runtime container for {mission_id} failed: {e}"
|
||
);
|
||
} else {
|
||
eprintln!(
|
||
"phase_runner: runtime container {name} → {} (paired={}) for mission {mission_id}",
|
||
ec.endpoint,
|
||
ec.pairing_code.is_some()
|
||
);
|
||
}
|
||
}
|
||
Err(e) => eprintln!(
|
||
"phase_runner: provision runtime container for {mission_id} failed (continuing): {e}"
|
||
),
|
||
}
|
||
}
|
||
|
||
// On a second or later pass, tell the agents what the evaluator found
|
||
// missing. This is what makes iteration converge instead of repeat — the
|
||
// same mechanism `/goal` uses when it feeds the evaluator's reason into
|
||
// the next turn, and that swarm.rs uses for rejected work.
|
||
let prior = crate::evaluator::latest(pool, phase_id)
|
||
.await
|
||
.unwrap_or(None);
|
||
let task = phase_task_text(kind, title, description, phase_task, has_repo);
|
||
let task = match prior {
|
||
Some((iter, false, guidance)) => format!(
|
||
"{task}\n\nPASS {} DID NOT SATISFY THE COMPLETION CONDITION. What is \
|
||
still missing:\n{guidance}\n\nDo the work this describes. Producing \
|
||
output that merely looks like it satisfies the check — printing an \
|
||
expected value, weakening a test, stubbing a result — fails the pass, \
|
||
because the condition is verified against the repository itself.",
|
||
iter + 1
|
||
),
|
||
_ => task,
|
||
};
|
||
|
||
// The container tier is deliberately NOT given this: it injects per-turn in
|
||
// `topology_exec`, with the running node's own role, and appending here too
|
||
// would put every crew member's skills in every turn twice.
|
||
let task_with_skills = match phase_skills_text(pool, mission_id).await {
|
||
// Always `Inline` here, and not because it is the default: the solo
|
||
// tiers get no skills door (`install_skills_door` runs only for a
|
||
// mission with its own container), so an index would list uris nothing
|
||
// in the VM can fetch. When the microVM tier folds onto
|
||
// `container_tool_hooks` this becomes a real choice; today it is a fact.
|
||
Some(skills) => crate::topology_exec::compose_turn_prompt(
|
||
&task,
|
||
Some(&skills),
|
||
crate::skill_delivery::Mode::Inline,
|
||
),
|
||
None => task.clone(),
|
||
};
|
||
|
||
|
||
// Direct-session executor: run the whole phase as ONE `claude -p` session
|
||
// against the mission checkout, instead of driving turns through ZeroClaw.
|
||
//
|
||
// Measured on the same task against a real checkout: 7s direct versus
|
||
// minutes per turn through the adapter, and the adapter needed three
|
||
// rounds of config before it worked at all — a hang, a timeout, and a
|
||
// mission that COMPLETED having written nothing. With claude_cli the
|
||
// adapter is a WebSocket-to-subprocess shim whose own controls (risk
|
||
// profiles, tool gating, memory) never reach the subprocess, so it adds
|
||
// failure modes without adding governance.
|
||
//
|
||
// It still creates one `topology_runs` row. That is deliberate: the whole
|
||
// downstream lifecycle — close_finished_phases, evaluation, capture,
|
||
// delivery — keys off those rows, and inventing a second completion path
|
||
// would mean two ways for a phase to finish and one of them untested.
|
||
// microVM executor. Checked BEFORE `direct_mode` because it is a property of
|
||
// the mission, not of the deployment: `runtime_kind='microvm'` was chosen for
|
||
// this mission specifically, and an env var that happens to be set must not
|
||
// silently run it somewhere else.
|
||
if p.runtime_kind == "microvm" {
|
||
// PLACEMENT, for both the solo and composed paths, in one place so they
|
||
// cannot disagree about which node this phase runs on.
|
||
//
|
||
// Per phase rather than per mission: re-placing is free because mission
|
||
// state lives on the gateway checkout (inject -> run -> collect ->
|
||
// destroy), so a node that filled or drained since the last phase simply
|
||
// is not chosen for the next one.
|
||
// The node this phase will use. Starts as the mission's current pin (a
|
||
// request), becomes whatever placement actually chose.
|
||
let mut chosen_node = p.target_node_id;
|
||
// A composed graph runs on ONE node, and its nodes may each name their
|
||
// own backend — an independent verifier on another provider is the
|
||
// roster's whole purpose. So the node must hold EVERY rootfs the graph
|
||
// asks for, not just the mission's. Read here rather than carried on
|
||
// `PhaseLaunch` because it is only the microVM path that cares.
|
||
let roster: Option<serde_json::Value> =
|
||
sqlx::query_scalar("SELECT config -> 'roster' FROM missions WHERE id = $1")
|
||
.bind(mission_id)
|
||
.fetch_optional(pool)
|
||
.await
|
||
.ok()
|
||
.flatten();
|
||
let backends = crate::vm_placement::required_backends(p.backend, roster.as_ref());
|
||
if backends.len() > 1 {
|
||
eprintln!(
|
||
"phase_runner: mission {mission_id} phase {phase_id} needs {backends:?} \
|
||
on a single node (composed graph with per-node backends)"
|
||
);
|
||
}
|
||
match crate::vm_placement::choose(pool, hub, workspace_id, &backends, chosen_node).await {
|
||
Ok(node) => {
|
||
if chosen_node != Some(node.as_uuid()) {
|
||
eprintln!(
|
||
"phase_runner: mission {mission_id} phase {phase_id} placed on node {} \
|
||
(was {:?})",
|
||
node.as_uuid(),
|
||
chosen_node
|
||
);
|
||
}
|
||
// Pin for the executors, which read `missions.target_node_id`,
|
||
// and clear any capacity wait now that one is satisfied.
|
||
let _ = sqlx::query(
|
||
"UPDATE missions SET target_node_id = $1, updated_at = now() WHERE id = $2",
|
||
)
|
||
.bind(node.as_uuid())
|
||
.bind(mission_id)
|
||
.execute(pool)
|
||
.await;
|
||
let _ = sqlx::query(
|
||
"UPDATE mission_phases
|
||
SET capacity_blocked_since = NULL, capacity_note = NULL
|
||
WHERE id = $1 AND capacity_blocked_since IS NOT NULL",
|
||
)
|
||
.bind(phase_id)
|
||
.execute(pool)
|
||
.await;
|
||
chosen_node = Some(node.as_uuid());
|
||
}
|
||
// TRANSIENT: the fleet is full, or we cannot read it. Leave the phase
|
||
// `pending` — `start_pending_phases` retries every 10s, and that loop
|
||
// IS the queue. No topology_runs row is created, so nothing downstream
|
||
// sees a run that never happened.
|
||
Err(e) if e.is_transient() => {
|
||
let msg = e.message();
|
||
// Recorded, not just logged: a phase waiting for capacity and a
|
||
// phase nothing is working on look identical from the outside,
|
||
// and this codebase has paid for that confusion repeatedly.
|
||
let blocked_for = record_capacity_block(pool, phase_id, &msg).await;
|
||
if blocked_for >= CAPACITY_WAIT_MAX_SECS {
|
||
eprintln!(
|
||
"phase_runner: mission {mission_id} phase {phase_id} waited \
|
||
{blocked_for:.0}s for a VM slot — failing it.\n{msg}"
|
||
);
|
||
let _ = sqlx::query(
|
||
"UPDATE mission_phases SET status = 'failed', completed_at = now(),
|
||
capacity_note = $2 WHERE id = $1",
|
||
)
|
||
.bind(phase_id)
|
||
.bind(format!(
|
||
"waited {blocked_for:.0}s for fleet capacity:\n{msg}"
|
||
))
|
||
.execute(pool)
|
||
.await;
|
||
return Err(format!("phase {phase_id} exhausted its capacity wait"));
|
||
}
|
||
eprintln!(
|
||
"phase_runner: mission {mission_id} phase {phase_id} WAITING for a VM \
|
||
slot ({blocked_for:.0}s so far)\n{msg}"
|
||
);
|
||
return Ok(());
|
||
}
|
||
// Not transient — no node could ever run this backend. Waiting will
|
||
// not fix it, so fail the phase rather than queue it forever.
|
||
Err(e) => return Err(e.message()),
|
||
}
|
||
|
||
// A composed mission's graph comes from the same `mission_teams` row a
|
||
// ZeroClaw mission would use — only its nodes run as VMs instead of
|
||
// claws. That is the whole point of composing the engines: the graph,
|
||
// the planners and the durability are Engine Z's, unchanged.
|
||
if wants_composed(p.team_engine) {
|
||
let team = team_rows
|
||
.iter()
|
||
.map(|r| {
|
||
(
|
||
r.get::<Uuid, _>("team_id"),
|
||
r.get::<serde_json::Value, _>("graph"),
|
||
)
|
||
})
|
||
.collect::<Vec<_>>();
|
||
return launch_composed_microvm_phase(
|
||
pool,
|
||
mission_id,
|
||
phase_id,
|
||
workspace_id,
|
||
iteration,
|
||
&task_with_skills,
|
||
team,
|
||
purposes,
|
||
)
|
||
.await;
|
||
}
|
||
return launch_microvm_phase(
|
||
pool,
|
||
hub,
|
||
mission_id,
|
||
phase_id,
|
||
kind,
|
||
workspace_id,
|
||
iteration,
|
||
&task_with_skills,
|
||
p.backend,
|
||
chosen_node,
|
||
p.team_engine,
|
||
crate::vm_stop_gate::StopGate::for_phase(kind, p.config),
|
||
has_repo,
|
||
)
|
||
.await;
|
||
}
|
||
|
||
if crate::session_executor::direct_mode() {
|
||
return launch_direct_session(
|
||
pool,
|
||
mission_id,
|
||
phase_id,
|
||
workspace_id,
|
||
iteration,
|
||
&task_with_skills,
|
||
)
|
||
.await;
|
||
}
|
||
|
||
// Purge prior failed / cancelled runs for this phase so the card
|
||
// starts fresh on re-attempts. Completed runs are kept for
|
||
// auditability (a mission that succeeded once and got re-run
|
||
// still shows both), but the failure noise from earlier attempts
|
||
// doesn't clutter the retry.
|
||
sqlx::query(
|
||
"DELETE FROM topology_runs
|
||
WHERE mission_phase_id = $1
|
||
AND status IN ('failed', 'cancelled')",
|
||
)
|
||
.bind(phase_id)
|
||
.execute(pool)
|
||
.await
|
||
.map_err(|e| format!("purge prior failed runs for phase {phase_id}: {e}"))?;
|
||
|
||
for r in &team_rows {
|
||
let team_id: Uuid = r.get("team_id");
|
||
let graph: serde_json::Value = r.get("graph");
|
||
// Inject each node's explicit `agent` alias so the executor
|
||
// dials the exact claw provisioned for THIS team's role,
|
||
// instead of falling through to the env-based ZEROCLAW_AGENT_MAP
|
||
// (which points at ambient names that don't exist per-team).
|
||
let graph = inject_node_agents(pool, team_id, graph).await;
|
||
let run_id = Uuid::now_v7();
|
||
sqlx::query(
|
||
"INSERT INTO topology_runs
|
||
(id, workspace_id, task, kind, status, graph, tier,
|
||
team_id, mission_id, mission_phase_id, iteration)
|
||
VALUES ($1, $2, $3, 'run', 'queued', $4, 'team', $5, $6, $7, $8)",
|
||
)
|
||
.bind(run_id)
|
||
.bind(workspace_id)
|
||
.bind(&task)
|
||
.bind(&graph)
|
||
.bind(team_id)
|
||
.bind(mission_id)
|
||
.bind(phase_id)
|
||
// Stamps which pass produced this run, so the "all runs finished?"
|
||
// check can't be satisfied by a previous pass's completed rows.
|
||
.bind(iteration)
|
||
.execute(pool)
|
||
.await
|
||
.map_err(|e| format!("enqueue run for team {team_id}: {e}"))?;
|
||
}
|
||
|
||
// Flip phase to running.
|
||
mark_phase_running(pool, mission_id, phase_id).await?;
|
||
eprintln!(
|
||
"phase_runner: mission {mission_id} phase {phase_id} ({kind}) launched with {} team(s)",
|
||
team_rows.len()
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
/// Launch a phase as a single headless session.
|
||
///
|
||
/// Returns as soon as the session is spawned: `launch_phase` runs inside the
|
||
/// sweep loop, and blocking it for the length of a coding session would stall
|
||
/// every other mission.
|
||
/// Does this mission want the composed engines — a durable ZeroClaw graph whose
|
||
/// every node is a Claude-Code-in-a-microVM session?
|
||
///
|
||
/// The third `team_engine` name migration 0069 anticipated. Exact match, like
|
||
/// `wants_claude_code_team`: a typo must run solo rather than half-compose.
|
||
fn wants_composed(team_engine: Option<&str>) -> bool {
|
||
matches!(team_engine, Some("composed"))
|
||
}
|
||
|
||
/// Enqueue a composed run — Slice 4's producer side.
|
||
///
|
||
/// Unlike every other executor here this one does **not** run the work: it emits
|
||
/// ONE `queued` row carrying the real graph and lets `topology_worker` claim it.
|
||
/// That is where the durability comes from — the checkpoint, the resume after a
|
||
/// crash, and the cancellation all belong to the worker, and a run that drove
|
||
/// itself from a `tokio::spawn` (as the solo microVM path does) would have none
|
||
/// of them.
|
||
///
|
||
/// One row, not one per team, and the constraint is physical: every node injects
|
||
/// from and collects back over the SAME host checkout, so two concurrent runs of
|
||
/// one phase would be two VMs writing one directory. A mission with two matching
|
||
/// teams is refused, visibly, rather than silently running only the first.
|
||
#[allow(clippy::too_many_arguments)]
|
||
async fn launch_composed_microvm_phase(
|
||
pool: &PgPool,
|
||
mission_id: Uuid,
|
||
phase_id: Uuid,
|
||
workspace_id: Uuid,
|
||
iteration: i32,
|
||
task: &str,
|
||
teams: Vec<(Uuid, serde_json::Value)>,
|
||
purposes: &[&str],
|
||
) -> Result<(), String> {
|
||
record_phase_prompt(pool, mission_id, phase_id, "composed_microvm", task).await;
|
||
sqlx::query(
|
||
"DELETE FROM topology_runs
|
||
WHERE mission_phase_id = $1 AND status IN ('failed', 'cancelled')",
|
||
)
|
||
.bind(phase_id)
|
||
.execute(pool)
|
||
.await
|
||
.map_err(|e| format!("purge prior runs for phase {phase_id}: {e}"))?;
|
||
|
||
// Refusals are recorded as a failed run rather than returned as an error:
|
||
// `launch_phase` is called from the sweep every ten seconds, so a returned
|
||
// error is a phase that retries forever while the log repeats itself. A
|
||
// failed row closes the phase and says why on the card.
|
||
let refuse = |why: String| async move {
|
||
eprintln!("phase_runner: composed phase {phase_id} of mission {mission_id} refused: {why}");
|
||
let _ = sqlx::query(
|
||
"INSERT INTO topology_runs
|
||
(id, workspace_id, task, kind, status, graph, tier,
|
||
mission_id, mission_phase_id, iteration, error)
|
||
VALUES ($1, $2, $3, 'run', 'failed', $4, 'microvm_graph', $5, $6, $7, $8)",
|
||
)
|
||
.bind(Uuid::now_v7())
|
||
.bind(workspace_id)
|
||
.bind(task)
|
||
.bind(serde_json::json!({ "nodes": [], "edges": [] }))
|
||
.bind(mission_id)
|
||
.bind(phase_id)
|
||
.bind(iteration)
|
||
.bind(&why)
|
||
.execute(pool)
|
||
.await;
|
||
// The phase must still leave `pending`, or the sweep re-launches it.
|
||
let _ = mark_phase_running(pool, mission_id, phase_id).await;
|
||
Ok(())
|
||
};
|
||
|
||
let (team_id, graph) = match teams.len() {
|
||
1 => {
|
||
let (id, g) = teams.into_iter().next().expect("len == 1");
|
||
(Some(id), g)
|
||
}
|
||
// The usual case, and not an error: `mission_orchestrator::on_launch`
|
||
// deliberately mints NO team for a microVM mission, because claws in
|
||
// containers are exactly what a VM mission does not use. A composed run
|
||
// needs the template's shape, not its claws, so it builds the graph from
|
||
// the template directly.
|
||
0 => match crate::mission_orchestrator::composed_graph(pool, mission_id, purposes).await {
|
||
Ok(Some(g)) => (None, g),
|
||
Ok(None) => {
|
||
return refuse(
|
||
"team_engine='composed' needs a team template to give the run its \
|
||
shape, and this mission picked none — a composed mission is a \
|
||
ZeroClaw graph whose nodes happen to be VMs, so without the graph \
|
||
there is nothing to compose"
|
||
.to_string(),
|
||
)
|
||
.await
|
||
}
|
||
Err(e) => return refuse(format!("could not build the composed graph: {e}")).await,
|
||
},
|
||
n => {
|
||
return refuse(format!(
|
||
"{n} teams match this phase, and a composed run must be exactly one: \
|
||
every node injects from and collects back over the same host \
|
||
checkout, so two runs would be two VMs writing one directory"
|
||
))
|
||
.await
|
||
}
|
||
};
|
||
|
||
// Parse here rather than letting the worker discover it: a graph the
|
||
// orchestrator cannot plan would otherwise be claimed, fail with "missing or
|
||
// invalid graph", and look like a runtime fault instead of a bad team.
|
||
if let Err(e) = serde_json::from_value::<cm_topology::TopologyGraph>(graph.clone()) {
|
||
return refuse(format!(
|
||
"the graph for this composed phase (team {team_id:?}) is not a runnable \
|
||
topology: {e}"
|
||
))
|
||
.await;
|
||
}
|
||
|
||
let run_id = Uuid::now_v7();
|
||
sqlx::query(
|
||
"INSERT INTO topology_runs
|
||
(id, workspace_id, task, kind, status, graph, tier,
|
||
team_id, mission_id, mission_phase_id, iteration)
|
||
VALUES ($1, $2, $3, 'run', 'queued', $4, 'microvm_graph', $5, $6, $7, $8)",
|
||
)
|
||
.bind(run_id)
|
||
.bind(workspace_id)
|
||
.bind(task)
|
||
.bind(&graph)
|
||
.bind(team_id)
|
||
.bind(mission_id)
|
||
.bind(phase_id)
|
||
.bind(iteration)
|
||
.execute(pool)
|
||
.await
|
||
.map_err(|e| format!("enqueue composed run for phase {phase_id}: {e}"))?;
|
||
|
||
mark_phase_running(pool, mission_id, phase_id).await?;
|
||
|
||
eprintln!(
|
||
"phase_runner: mission {mission_id} phase {phase_id} queued as a COMPOSED run \
|
||
{run_id} (team {team_id:?}) — a ZeroClaw graph with microVM nodes"
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
/// Launch a phase inside a Firecracker microVM on the mission's placed node.
|
||
///
|
||
/// Mirrors [`launch_direct_session`] on purpose, down to creating exactly ONE
|
||
/// `topology_runs` row: `close_finished_phases`, evaluation, capture and delivery
|
||
/// all key off those rows, and a second completion path would be a second way for
|
||
/// a phase to finish with one of them untested.
|
||
///
|
||
/// The run row and the phase flip happen **before** any fallible VM work, so a
|
||
/// configuration error (no subscription token, a node that lost its capability)
|
||
/// surfaces as a failed run an operator can see — not as a phase that stays
|
||
/// `pending` and is retried every ten seconds forever.
|
||
#[allow(clippy::too_many_arguments)]
|
||
async fn launch_microvm_phase(
|
||
pool: &PgPool,
|
||
hub: &std::sync::Arc<crate::fleet::NodeHub>,
|
||
mission_id: Uuid,
|
||
phase_id: Uuid,
|
||
// The phase kind, used to label this run's single checkpoint record.
|
||
kind: &str,
|
||
workspace_id: Uuid,
|
||
iteration: i32,
|
||
task: &str,
|
||
backend: Option<&str>,
|
||
target_node_id: Option<Uuid>,
|
||
team_engine: Option<&str>,
|
||
gate: Option<crate::vm_stop_gate::StopGate>,
|
||
// Whether the mission has a repository. A repo-less mission gets an EMPTY
|
||
// workspace at the same guest path instead of a checkout — see
|
||
// `VmPhase::has_repo`.
|
||
has_repo: bool,
|
||
) -> Result<(), String> {
|
||
record_phase_prompt(pool, mission_id, phase_id, "microvm", task).await;
|
||
sqlx::query(
|
||
"DELETE FROM topology_runs
|
||
WHERE mission_phase_id = $1 AND status IN ('failed', 'cancelled')",
|
||
)
|
||
.bind(phase_id)
|
||
.execute(pool)
|
||
.await
|
||
.map_err(|e| format!("purge prior runs for phase {phase_id}: {e}"))?;
|
||
|
||
let run_id = Uuid::now_v7();
|
||
sqlx::query(
|
||
"INSERT INTO topology_runs
|
||
(id, workspace_id, task, kind, status, graph, tier,
|
||
mission_id, mission_phase_id, iteration)
|
||
VALUES ($1, $2, $3, 'run', 'running', $4, 'microvm', $5, $6, $7)",
|
||
)
|
||
.bind(run_id)
|
||
.bind(workspace_id)
|
||
.bind(task)
|
||
.bind(serde_json::json!({ "nodes": [], "edges": [], "executor": "microvm" }))
|
||
.bind(mission_id)
|
||
.bind(phase_id)
|
||
.bind(iteration)
|
||
.execute(pool)
|
||
.await
|
||
.map_err(|e| format!("enqueue microvm run for phase {phase_id}: {e}"))?;
|
||
|
||
mark_phase_running(pool, mission_id, phase_id).await?;
|
||
|
||
let repo = crate::mission_workspace::checkout_path(mission_id);
|
||
let task = task.to_string();
|
||
let backend = backend.map(str::to_string);
|
||
let team_engine = team_engine.map(str::to_string);
|
||
let pool2 = pool.clone();
|
||
let hub = hub.clone();
|
||
// Moved in for the checkpoint record below: the reader labels each record by
|
||
// role, and for a solo run the phase kind is the only role there is.
|
||
let phase_kind = kind.to_string();
|
||
tokio::spawn(async move {
|
||
// Everything fallible lives in here, so every outcome closes the run.
|
||
let outcome = async {
|
||
let node = target_node_id.ok_or_else(|| {
|
||
"mission has no target_node_id — placement should have set one at \
|
||
launch; a microvm mission cannot run on the gateway, which has no \
|
||
/dev/kvm"
|
||
.to_string()
|
||
})?;
|
||
// A repo-BACKED mission with no checkout is a real fault: something
|
||
// went wrong before the phase, and booting a VM to hand the agent an
|
||
// empty directory would turn it into a confusing agent report.
|
||
//
|
||
// A repo-LESS mission is a different thing entirely, and this guard
|
||
// used to refuse it too — "a microvm phase needs a repository" was
|
||
// the third of three places that assumed so, alongside the inject
|
||
// and the readiness probe. It gets an empty workspace instead, made
|
||
// here so the executor's inject has something to pack.
|
||
if has_repo && !repo.is_dir() {
|
||
return Err(format!(
|
||
"mission has no checkout at {} — a repo-backed microvm phase \
|
||
needs one",
|
||
repo.display()
|
||
));
|
||
}
|
||
if !has_repo {
|
||
std::fs::create_dir_all(&repo)
|
||
.map_err(|e| format!("create empty workspace {}: {e}", repo.display()))?;
|
||
}
|
||
crate::microvm_executor::run_phase_in_vm(
|
||
&hub,
|
||
crate::microvm_executor::VmPhase {
|
||
// Attribution for live output: this is the run a browser
|
||
// subscribes to for this phase.
|
||
run_id: Some(run_id),
|
||
node_id: cm_domain::NodeId::from(node),
|
||
mission_id,
|
||
phase_id,
|
||
iteration,
|
||
task: &task,
|
||
backend: backend.as_deref(),
|
||
repo: &repo,
|
||
has_repo,
|
||
team_engine: team_engine.as_deref(),
|
||
gate: gate.as_ref(),
|
||
// The solo path is one VM for the whole phase; only a
|
||
// composed run needs the id qualified per graph node.
|
||
step: None,
|
||
// Live tool motion: the tap is drained WHILE the turn runs,
|
||
// so the World shows a coding phase touching files as it
|
||
// happens rather than an hour later, all at once.
|
||
tap_sink: Some(vm_tool_recorder(&pool2, mission_id, phase_id, run_id)),
|
||
},
|
||
)
|
||
.await
|
||
}
|
||
.await;
|
||
|
||
// The agent's own account is diagnostic only. Whether the phase succeeded
|
||
// is decided downstream by capture + delivery against the repository.
|
||
// Counted from the guest's own per-subagent transcripts. "-" means the
|
||
// probe could not run, which is not the same as "it delegated to nobody".
|
||
let subagents = match &outcome {
|
||
Ok(o) => o
|
||
.subagents
|
||
.map(|n| n.to_string())
|
||
.unwrap_or_else(|| "?".into()),
|
||
Err(_) => "-".into(),
|
||
};
|
||
// Only meaningful for a mission that asked for a team; "-" otherwise.
|
||
let teammates = match &outcome {
|
||
Ok(o) => o
|
||
.teammates
|
||
.map(|n| n.to_string())
|
||
.unwrap_or_else(|| "-".into()),
|
||
Err(_) => "-".into(),
|
||
};
|
||
// How often the completion gate sent the agent back inside its own turn.
|
||
// "-" is no gate; a number is how many second chances it took, which is
|
||
// the whole measurement of whether the gate is worth its hook.
|
||
let blocked = match &outcome {
|
||
Ok(o) => o
|
||
.stop_blocks
|
||
.map(|n| n.to_string())
|
||
.unwrap_or_else(|| "-".into()),
|
||
Err(_) => "-".into(),
|
||
};
|
||
// What the agent touched inside the VM, recorded before the outcome is
|
||
// consumed. Recorded whatever the phase's verdict: a phase that failed
|
||
// still did work, and the map of what it touched is exactly what makes
|
||
// the failure legible.
|
||
// With a sink attached, `tools` comes back EMPTY by contract — the
|
||
// recorder task has already written every batch, including the final
|
||
// one. Kept as a no-op rather than deleted so a future sink-less path
|
||
// still records; recording the same calls twice is what the empty
|
||
// contract exists to prevent.
|
||
if let Ok(o) = &outcome {
|
||
record_vm_tools(&pool2, mission_id, phase_id, run_id, &o.tools, &[]).await;
|
||
// The gate's own record, on the mission, in the container tier's
|
||
// vocabulary: `gate.inert` when it gave up parsing and allowed
|
||
// calls unchecked, `gate.denied` per call it refused. The guest
|
||
// wrote both files from day one; this is the first reader.
|
||
if let Some(g) = &o.tool_gate {
|
||
if g.inert > 0 {
|
||
crate::mission_events::record(
|
||
&pool2,
|
||
crate::mission_events::MissionEvent::new(
|
||
mission_id,
|
||
crate::container_tool_hooks::GATE_INERT,
|
||
)
|
||
.phase(phase_id)
|
||
.run(run_id)
|
||
.detail(serde_json::json!({ "occurrences": g.inert, "tier": "microvm" })),
|
||
)
|
||
.await;
|
||
}
|
||
for line in &g.denied {
|
||
let detail = serde_json::from_str::<serde_json::Value>(line)
|
||
.unwrap_or_else(|_| serde_json::json!({ "raw": line }));
|
||
crate::mission_events::record(
|
||
&pool2,
|
||
crate::mission_events::MissionEvent::new(mission_id, "gate.denied")
|
||
.phase(phase_id)
|
||
.run(run_id)
|
||
.detail(detail),
|
||
)
|
||
.await;
|
||
}
|
||
}
|
||
}
|
||
// What actually ran: the rootfs the node booted and the CLI the guest
|
||
// reported. Persisted on the run so "which image and version did this
|
||
// mission use" is a query, not an inference from file mtimes — on
|
||
// 2026-09-18 every fleet rootfs had sat on 2.1.223–2.1.226 for a month
|
||
// while the container tier moved on, and nothing had recorded either.
|
||
let vm = serde_json::json!({
|
||
"vm_id": crate::microvm_executor::vm_id_for(phase_id, iteration, None),
|
||
"node_id": target_node_id,
|
||
"backend": backend,
|
||
"rootfs": outcome.as_ref().ok().and_then(|o| o.rootfs.clone()),
|
||
"cli_version": outcome.as_ref().ok().and_then(|o| o.cli_version.clone()),
|
||
});
|
||
let (status, note) = match outcome {
|
||
// The gate gave up. It is the ONLY thing that runs a
|
||
// `done_when_check`, so a release at the cap means the phase's own
|
||
// completion condition was still failing when the agent stopped and
|
||
// nothing downstream will ever re-run it. Completing here is the
|
||
// silent-success shape: green phase, unmet condition, no error.
|
||
Ok(o) if o.released_at_cap == Some(true) => (
|
||
"failed",
|
||
format!(
|
||
"the completion gate released the agent after {} refusal(s) with its \
|
||
check still failing: {}",
|
||
crate::vm_stop_gate::MAX_BLOCKS,
|
||
o.summary
|
||
),
|
||
),
|
||
Ok(o) if o.rc == 0 && o.collected => ("completed", o.summary),
|
||
// A turn that ran and could not be collected is a failure even when
|
||
// the agent was satisfied: the work did not reach the host, so there
|
||
// is nothing for delivery to find.
|
||
Ok(o) if !o.collected => (
|
||
"failed",
|
||
format!(
|
||
"the agent's work could not be collected from the VM: {}",
|
||
o.summary
|
||
),
|
||
),
|
||
Ok(o) => ("failed", o.summary),
|
||
Err(e) => ("failed", e),
|
||
};
|
||
eprintln!(
|
||
"phase_runner: microvm phase {phase_id} of mission {mission_id} → {status} \
|
||
(subagents: {subagents}, teammates: {teammates}, stop-gate blocks: \
|
||
{blocked}; rootfs: {}, cli: {}) — {}",
|
||
vm["rootfs"].as_str().unwrap_or("?"),
|
||
vm["cli_version"].as_str().unwrap_or("?"),
|
||
note.chars().take(300).collect::<String>()
|
||
);
|
||
// Never overwrite a cancellation. The operator asking to stop is a decision;
|
||
// this task reporting how the VM turned out is an observation, and it may
|
||
// land minutes later. Without the guard a cancelled run silently reappears
|
||
// as completed or failed.
|
||
// Persist the turn as a checkpoint RECORD, not just a status.
|
||
//
|
||
// Everything the UI shows of a run's content reads
|
||
// `topology_runs.checkpoint.records`: `/api/missions/{id}/documents`
|
||
// (the output reader) and `/api/topology-runs/{id}/events` (the live
|
||
// pane). The composed and team tiers write it; the SOLO microVM path
|
||
// never did — measured as `checkpoint IS NULL, records = 0` for every
|
||
// `tier='microvm'` run, against 5 records for `team`.
|
||
//
|
||
// So a solo microVM mission produced real work and showed the operator
|
||
// an empty Live tab and an empty Output tab, with the agent's own
|
||
// account of the turn going to stderr and nowhere else.
|
||
//
|
||
// Shape matches what those two readers already parse — role, node_id,
|
||
// output — so no reader changes.
|
||
let record = serde_json::json!({
|
||
"records": [{
|
||
"node_id": "n0",
|
||
"role": phase_kind,
|
||
"phase": "work",
|
||
"output": note,
|
||
"tokens": 0,
|
||
"gated": [],
|
||
}],
|
||
// Beside `records`, not inside: the two readers parse only `records`.
|
||
"vm": vm,
|
||
});
|
||
if let Err(e) = sqlx::query(
|
||
"UPDATE topology_runs
|
||
SET status = $2,
|
||
checkpoint = COALESCE(checkpoint, '{}'::jsonb) || $3::jsonb,
|
||
updated_at = now()
|
||
WHERE id = $1 AND status <> 'cancelled'",
|
||
)
|
||
.bind(run_id)
|
||
.bind(status)
|
||
.bind(&record)
|
||
.execute(&pool2)
|
||
.await
|
||
{
|
||
eprintln!("phase_runner: could not close microvm run {run_id}: {e}");
|
||
}
|
||
});
|
||
|
||
eprintln!(
|
||
"phase_runner: mission {mission_id} phase {phase_id} launched in a MICROVM \
|
||
on node {target_node_id:?}"
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
async fn launch_direct_session(
|
||
pool: &PgPool,
|
||
mission_id: Uuid,
|
||
phase_id: Uuid,
|
||
workspace_id: Uuid,
|
||
iteration: i32,
|
||
task: &str,
|
||
) -> Result<(), String> {
|
||
record_phase_prompt(pool, mission_id, phase_id, "session", task).await;
|
||
sqlx::query(
|
||
"DELETE FROM topology_runs
|
||
WHERE mission_phase_id = $1 AND status IN ('failed', 'cancelled')",
|
||
)
|
||
.bind(phase_id)
|
||
.execute(pool)
|
||
.await
|
||
.map_err(|e| format!("purge prior runs for phase {phase_id}: {e}"))?;
|
||
|
||
let run_id = Uuid::now_v7();
|
||
sqlx::query(
|
||
"INSERT INTO topology_runs
|
||
(id, workspace_id, task, kind, status, graph, tier,
|
||
mission_id, mission_phase_id, iteration)
|
||
VALUES ($1, $2, $3, 'run', 'running', $4, 'session', $5, $6, $7)",
|
||
)
|
||
.bind(run_id)
|
||
.bind(workspace_id)
|
||
.bind(task)
|
||
.bind(serde_json::json!({ "nodes": [], "edges": [], "executor": "session" }))
|
||
.bind(mission_id)
|
||
.bind(phase_id)
|
||
.bind(iteration)
|
||
.execute(pool)
|
||
.await
|
||
.map_err(|e| format!("enqueue session run for phase {phase_id}: {e}"))?;
|
||
|
||
mark_phase_running(pool, mission_id, phase_id).await?;
|
||
|
||
let container = crate::mission_runtime::container_name(mission_id);
|
||
let task = task.to_string();
|
||
let pool = pool.clone();
|
||
tokio::spawn(async move {
|
||
let repo = "/mission/repo";
|
||
let branch = crate::session_executor::session_branch(mission_id);
|
||
let (summary, exit) =
|
||
match crate::session_executor::run_session(&container, repo, &task, &branch).await {
|
||
Ok(v) => v,
|
||
Err(e) => (format!("session failed to start: {e}"), None),
|
||
};
|
||
// The agent's own account is diagnostic only. Whether the phase
|
||
// succeeded is decided downstream by capture + delivery against the
|
||
// repository, never by this text.
|
||
let ok = exit == Some(0);
|
||
eprintln!(
|
||
"phase_runner: session for mission {mission_id} phase {phase_id} exited {exit:?} — {}",
|
||
summary.chars().take(200).collect::<String>()
|
||
);
|
||
let status = if ok { "completed" } else { "failed" };
|
||
|
||
// The agent's account, kept. Until now this tier wrote NO checkpoint
|
||
// record — the same defect the solo microVM path was fixed for, in the
|
||
// one remaining tier: `summary` went to stderr above and nowhere else,
|
||
// so the Live and Output tabs were empty for a session that did real
|
||
// work, and nothing downstream could read what the agent said it did.
|
||
//
|
||
// Shape matches the microVM path exactly, so the two existing readers
|
||
// (`/api/missions/{id}/documents` and `/api/topology-runs/{id}/events`)
|
||
// need no change.
|
||
let record = serde_json::json!({
|
||
"records": [{
|
||
"node_id": "n0",
|
||
"role": "session",
|
||
"phase": "work",
|
||
"output": summary,
|
||
"tokens": 0,
|
||
"gated": [],
|
||
}]
|
||
});
|
||
// Also as a durable event, so the narrative is queryable per mission
|
||
// rather than only by walking topology_runs JSON.
|
||
let mut ev = crate::mission_events::MissionEvent::new(
|
||
mission_id,
|
||
crate::mission_events::REASONING,
|
||
);
|
||
ev.phase_id = Some(phase_id);
|
||
ev.run_id = Some(run_id);
|
||
ev.target = Some("session".to_string());
|
||
ev.detail = serde_json::json!({ "text": summary });
|
||
crate::mission_events::record(&pool, ev).await;
|
||
|
||
// Never overwrite a cancellation. The operator asking to stop is a decision;
|
||
// this task reporting how the VM turned out is an observation, and it may
|
||
// land minutes later. Without the guard a cancelled run silently reappears
|
||
// as completed or failed.
|
||
if let Err(e) = sqlx::query(
|
||
"UPDATE topology_runs
|
||
SET status = $2,
|
||
checkpoint = COALESCE(checkpoint, '{}'::jsonb) || $3::jsonb,
|
||
updated_at = now()
|
||
WHERE id = $1 AND status <> 'cancelled'",
|
||
)
|
||
.bind(run_id)
|
||
.bind(status)
|
||
.bind(&record)
|
||
.execute(&pool)
|
||
.await
|
||
{
|
||
eprintln!("phase_runner: could not close session run {run_id}: {e}");
|
||
}
|
||
});
|
||
|
||
eprintln!("phase_runner: mission {mission_id} phase {phase_id} launched as a DIRECT SESSION");
|
||
Ok(())
|
||
}
|
||
|
||
/// The pinned skills for a phase's crew, rendered for the task text.
|
||
///
|
||
/// The container tier gets skills per TURN (`topology_exec::pinned_skills_text`),
|
||
/// where each node carries its own claw alias and therefore its own role's
|
||
/// skills. The microVM and direct-session tiers have no per-turn alias — the
|
||
/// phase runs as one `claude -p` session — so their skills have to be resolved
|
||
/// per PHASE and appended to the task instead. Without this, those three tiers
|
||
/// deliver no skill at all, which is what they did until now.
|
||
///
|
||
/// Union across the crew, deduplicated. A solo tier does not know which crew
|
||
/// member's turn it is running, and a procedure that applies to the role doing
|
||
/// the work still applies when one agent does all of it. Erring toward the
|
||
/// union is safe here in a way it would not be on the container tier, where
|
||
/// per-role precision is available and used.
|
||
/// Record the prompt a phase is about to run with.
|
||
///
|
||
/// Called from inside each tier rather than at the dispatch point, because the
|
||
/// tiers do not all send the same text: the three solo tiers send the
|
||
/// skills-bearing task, and the container tier sends the bare task and appends
|
||
/// skills per turn in `topology_exec`. Recording at the fork wrote a `solo`
|
||
/// prompt for container missions too — a prompt that was composed and never
|
||
/// sent. A provenance record of something that did not happen is worse than no
|
||
/// record: it is the wrong answer, delivered confidently.
|
||
async fn record_phase_prompt(
|
||
pool: &PgPool,
|
||
mission_id: Uuid,
|
||
phase_id: Uuid,
|
||
tier: &str,
|
||
text: &str,
|
||
) {
|
||
let mut ev = crate::mission_events::MissionEvent::new(
|
||
mission_id,
|
||
crate::mission_events::PROMPT_COMPOSED,
|
||
);
|
||
ev.phase_id = Some(phase_id);
|
||
ev.target = Some(tier.to_string());
|
||
ev.detail = serde_json::json!({ "text": text, "tier": tier });
|
||
crate::mission_events::record(pool, ev).await;
|
||
}
|
||
|
||
pub async fn phase_skills_text(pool: &PgPool, mission_id: Uuid) -> Option<String> {
|
||
let crew = sqlx::query(
|
||
"SELECT DISTINCT a.id
|
||
FROM team_members tm
|
||
JOIN mission_teams mt ON mt.team_id = tm.team_id
|
||
JOIN agents a ON a.id = tm.claw_id
|
||
WHERE mt.mission_id = $1 AND a.deleted_at IS NULL",
|
||
)
|
||
.bind(mission_id)
|
||
.fetch_all(pool)
|
||
.await
|
||
.unwrap_or_default();
|
||
|
||
let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
|
||
let mut out = String::new();
|
||
for row in &crew {
|
||
let agent_id: Uuid = row.get("id");
|
||
let link = cm_db::repo::agent_template_link::get(pool, agent_id)
|
||
.await
|
||
.ok()
|
||
.flatten();
|
||
let (tpl_id, slot) = link
|
||
.as_ref()
|
||
.map(|l| (Some(l.template_id), Some(l.role_slot.as_str())))
|
||
.unwrap_or((None, None));
|
||
let Ok(bindings) =
|
||
cm_db::repo::skills_catalog::effective_for_agent(pool, agent_id, tpl_id, slot).await
|
||
else {
|
||
continue;
|
||
};
|
||
for b in bindings.iter().filter(|b| b.pin_in_context) {
|
||
if !seen.insert(b.skill.name.clone()) {
|
||
continue;
|
||
}
|
||
// Bounded, and truncation is STATED. A silently clipped procedure is
|
||
// worse than an absent one: the agent follows the half it can see
|
||
// and reports success against a rule it never read.
|
||
if out.len() + b.skill.body.len() > crate::topology_exec::MAX_PINNED_SKILL_BYTES {
|
||
out.push_str(&format!(
|
||
"\n[skill \"{}\" omitted — the pinned set exceeded {} bytes]\n",
|
||
b.skill.name,
|
||
crate::topology_exec::MAX_PINNED_SKILL_BYTES
|
||
));
|
||
continue;
|
||
}
|
||
out.push_str(&crate::topology_exec::render_pinned_skill(
|
||
&b.skill.name,
|
||
&b.skill.body,
|
||
));
|
||
}
|
||
}
|
||
if seen.is_empty() {
|
||
return None;
|
||
}
|
||
Some(out)
|
||
}
|
||
|
||
fn phase_task_text(
|
||
kind: &str,
|
||
title: &str,
|
||
description: Option<&str>,
|
||
phase_task: Option<&str>,
|
||
// Whether the mission has a repository bound. A repo-less mission's
|
||
// `/mission/repo` is a scratch directory, and telling its agents otherwise
|
||
// is how eight research documents were written into a container that was
|
||
// then reaped unread.
|
||
has_repo: bool,
|
||
) -> String {
|
||
let base = description.unwrap_or("").trim();
|
||
// These are Claude Code's OWN tool names, because every executor that runs a
|
||
// mission turn ends in `claude -p`: the microVM passes
|
||
// `--allowedTools Read Edit Write Bash Agent`
|
||
// (`microvm_executor::LEAD_TOOLS`), the direct path passes
|
||
// `Read Edit Write Bash` (`session_executor::ALLOWED_TOOLS`), and the
|
||
// container tier binds every claw to `claude_cli.default`
|
||
// (`runtime_provision::provider_alias_for`), whose subprocess gets Claude
|
||
// Code's native toolset — ZeroClaw's own tool gating "never reaches the
|
||
// subprocess" (see the note above `direct_mode`).
|
||
//
|
||
// This block previously advertised ZeroClaw tool names (`file_edit`,
|
||
// `content_search`, …) and explicitly told the agent that `bash` did NOT
|
||
// exist. On every tier in service that was backwards: those tools were the
|
||
// ones absent, and Bash was one of the ones present. Agents answered by
|
||
// describing the mismatch and asking what to do — five of them, on one
|
||
// mission, for 7.4k tokens and zero artifacts.
|
||
let tool_preamble = format!(
|
||
"\
|
||
TOOLS AVAILABLE (Claude Code's standard tools — use these exact names):\n\
|
||
- Read — read a file\n\
|
||
- Edit — modify an existing file\n\
|
||
- Write — create or overwrite a file\n\
|
||
- Bash — run a shell command\n\
|
||
- Glob — find files by path glob\n\
|
||
- Grep — search file contents\n\
|
||
\n\
|
||
{workspace}\n\
|
||
All file operations resolve there — use absolute paths under it, or cd\n\
|
||
there first. Write your outputs as REAL files with Write/Edit — do NOT\n\
|
||
paste code blocks in your reply expecting the platform to save them;\n\
|
||
nothing else writes files for you.\n",
|
||
workspace = if has_repo {
|
||
"WORKSPACE: Your working directory is /mission/repo. That path is the\n\
|
||
mission's git checkout."
|
||
} else {
|
||
// Saying "git checkout" here to a mission that has none is what
|
||
// produced the eight destroyed ClawHDF5 documents: the agent looked,
|
||
// found no repo, wrote the files anyway, and nothing collected them.
|
||
// Now `mission_outputs` DOES collect them, and the agent is told so
|
||
// — an instruction the platform can actually keep.
|
||
"WORKSPACE: Your working directory is /mission/repo. It EXISTS and is writable.\n\
|
||
This mission has NO git repository — that path is a scratch workspace, so\n\
|
||
there is nothing to commit or push. Every file you leave there is collected\n\
|
||
when the phase ends and published as a mission artifact, so write your\n\
|
||
output as files exactly as you would in a repo."
|
||
}
|
||
);
|
||
// The INT-XX markers are a machine contract, not a style preference:
|
||
// task_card_parser.rs scans turn output line-by-line for these literals and
|
||
// materializes `mission_tasks` rows from them. The rules used to live only
|
||
// in the team-template role prompts -- which are never injected into mission
|
||
// turns (runtime_provision.rs writes model/risk_profile/mcp_bundles and
|
||
// nothing else) -- and in a skill the agent had to choose to fetch. So the
|
||
// parser's contract was stated nowhere the agent reliably saw it. It is
|
||
// stated here because this is the one text every mission turn receives.
|
||
let marker_protocol = "\
|
||
TASK MARKERS (parsed literally, line by line — this is a machine contract):\n\
|
||
Emit these on their own line, with the colon, no bold, no code fence,\n\
|
||
exactly one INT id per line, at the END of a substantive turn:\n\
|
||
- TASK: INT-NN — <title> open a new item\n\
|
||
- WORK: INT-NN started implementing\n\
|
||
- HANDOFF: INT-NN passed to test/review\n\
|
||
- TEST_PASS: INT-NN tests green\n\
|
||
- TEST_FAIL: INT-NN — <reason> build/tests failed\n\
|
||
- REVIEW_APPROVE: INT-NN diff approved\n\
|
||
- REVIEW_BLOCK: INT-NN — <reason> changes requested\n\
|
||
- COMPLETED: INT-NN done and pushed\n\
|
||
Never emit a marker you can't back up — COMPLETED without a corresponding\n\
|
||
commit desynchronizes the mission from the repo.\n";
|
||
let directive = match kind {
|
||
"research" => {
|
||
"Your team is running the RESEARCH phase of this mission. \
|
||
Investigate the topic, gather sources, and produce a \
|
||
sectioned Markdown brief the coding phase can implement \
|
||
directly. Save findings under /mission/repo/research/ \
|
||
using Write — one Markdown file per topic. Emit INT-XX \
|
||
task markers in the last file for concrete follow-ups."
|
||
}
|
||
"coding" => {
|
||
"Your team is running the CODING phase of this mission. \
|
||
Implement the mission's acceptance criteria against the \
|
||
/mission/repo checkout using Write/Edit for every source \
|
||
file, then git via Bash to commit small focused changes \
|
||
with test coverage. Emit COMPLETED: <INT-id> markers as you \
|
||
close research-produced tasks. Do NOT respond with source \
|
||
code in text — write it as files."
|
||
}
|
||
"benchmark" => {
|
||
"Your team is running the BENCHMARK phase of this mission. \
|
||
Author or extend benchmarks under /mission/repo/benches or \
|
||
the crate's bench harness using Write/Edit. Baseline the \
|
||
pre-change performance, apply the change (or use the \
|
||
mission's committed diff), then measure after."
|
||
}
|
||
"security_scan" => {
|
||
"Your team is running the SECURITY SCAN phase of this mission. \
|
||
Run cargo-audit, gitleaks, trivy, and semgrep against \
|
||
/mission/repo. Triage findings, file INT-XX task markers \
|
||
for remediation, propose patches for the coding phase."
|
||
}
|
||
_ => "Execute this mission phase according to the mission brief.",
|
||
};
|
||
// The mission brief is shared by every phase; this block is not. It goes
|
||
// last and says so explicitly, because the failure it fixes was agents
|
||
// re-doing the whole mission in each phase rather than their slice of it.
|
||
let scope = match phase_task.map(str::trim).filter(|t| !t.is_empty()) {
|
||
Some(t) => format!(
|
||
"\n\nTHIS PHASE'S TASK — do this and only this. The brief above is \
|
||
the mission's full scope across all phases; the following is your \
|
||
share of it:\n{t}"
|
||
),
|
||
None => String::new(),
|
||
};
|
||
format!(
|
||
"MISSION: {title}\n\n{tool_preamble}\n{marker_protocol}\n{directive}\n\nBRIEF:\n{base}{scope}"
|
||
)
|
||
}
|
||
|
||
/// Record a microVM turn's drained tool tap.
|
||
///
|
||
/// `agent_id` is deliberately absent: a microVM phase has no platform agent, so
|
||
/// there is no pawn to attribute the touch to. Inventing one would put a named
|
||
/// crew member's face on work a VM did alone.
|
||
/// A sink that records tool observations into `mission_events` as they arrive,
|
||
/// plus the task draining it.
|
||
///
|
||
/// The channel exists so the VM executor stays free of the database: it
|
||
/// observes, this records. The task ends when the sender is dropped, which
|
||
/// happens when the phase's `VmPhase` goes out of scope — so there is no
|
||
/// lifetime to manage and no way to leak one per phase.
|
||
pub(crate) fn vm_tool_recorder(
|
||
pool: &PgPool,
|
||
mission_id: Uuid,
|
||
phase_id: Uuid,
|
||
run_id: Uuid,
|
||
) -> tokio::sync::mpsc::UnboundedSender<Vec<crate::vm_tool_tap::Observed>> {
|
||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Vec<crate::vm_tool_tap::Observed>>();
|
||
let pool = pool.clone();
|
||
tokio::spawn(async move {
|
||
while let Some(batch) = rx.recv().await {
|
||
record_vm_tools(&pool, mission_id, phase_id, run_id, &batch, &[]).await;
|
||
}
|
||
});
|
||
tx
|
||
}
|
||
|
||
/// Which agent each observed call belongs to, by session.
|
||
///
|
||
/// The tap is per-CONTAINER and every role in a phase shares one, so a phase's
|
||
/// calls arrive as one undifferentiated stream and `agent_id` was written
|
||
/// `None` for all of them. That is why every Skill-Use score is per-mission
|
||
/// rather than per-role, and why the World's per-agent view gets nothing from
|
||
/// this tier.
|
||
///
|
||
/// One `claude -p` invocation is one turn is one agent, and Claude Code stamps
|
||
/// each invocation with a `session_id`. So the distinct sessions, in the order
|
||
/// they first appear, are the phase's turns in the order they ran — and
|
||
/// `prompt.composed` already records the agent of each turn in that same order.
|
||
///
|
||
/// # It attributes nothing rather than guessing
|
||
///
|
||
/// Only when the counts match exactly. A phase whose sessions and turns differ
|
||
/// in number has something this correlation does not model — a retry, a turn
|
||
/// that called no tool, two agents genuinely concurrent — and a
|
||
/// plausible-looking wrong attribution is worse here than none: it would put
|
||
/// one agent's `git push` on another agent's record, which is the sort of thing
|
||
/// a person later reasons from.
|
||
pub(crate) fn attribute_sessions(
|
||
tools: &[crate::vm_tool_tap::Observed],
|
||
turn_agents: &[Uuid],
|
||
) -> Vec<Option<Uuid>> {
|
||
let mut order: Vec<&str> = Vec::new();
|
||
for t in tools {
|
||
let Some(sid) = t.session.as_deref() else {
|
||
// A single unattributable call means the sequence has a hole in it,
|
||
// and a hole shifts every later session onto the wrong turn.
|
||
return vec![None; tools.len()];
|
||
};
|
||
if !order.contains(&sid) {
|
||
order.push(sid);
|
||
}
|
||
}
|
||
if order.len() != turn_agents.len() || order.is_empty() {
|
||
return vec![None; tools.len()];
|
||
}
|
||
tools
|
||
.iter()
|
||
.map(|t| {
|
||
let sid = t.session.as_deref()?;
|
||
let idx = order.iter().position(|s| *s == sid)?;
|
||
turn_agents.get(idx).copied()
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
pub(crate) async fn record_vm_tools(
|
||
pool: &PgPool,
|
||
mission_id: Uuid,
|
||
phase_id: Uuid,
|
||
run_id: Uuid,
|
||
tools: &[crate::vm_tool_tap::Observed],
|
||
turn_agents: &[Uuid],
|
||
) {
|
||
if tools.is_empty() {
|
||
return;
|
||
}
|
||
let owners = attribute_sessions(tools, turn_agents);
|
||
if owners.iter().all(Option::is_none) && !turn_agents.is_empty() {
|
||
eprintln!(
|
||
"phase_runner: {} tool call(s) for phase {phase_id} could not be \
|
||
attributed to an agent ({} session(s) across {} turn(s)) — recorded \
|
||
unattributed rather than guessed",
|
||
tools.len(),
|
||
tools
|
||
.iter()
|
||
.filter_map(|t| t.session.as_deref())
|
||
.collect::<std::collections::HashSet<_>>()
|
||
.len(),
|
||
turn_agents.len()
|
||
);
|
||
}
|
||
let mut events = Vec::new();
|
||
for (t, owner) in tools.iter().zip(owners) {
|
||
events.push(crate::mission_events::MissionEvent {
|
||
mission_id,
|
||
phase_id: Some(phase_id),
|
||
run_id: Some(run_id),
|
||
agent_id: owner,
|
||
kind: crate::mission_events::TOOL_CALL.to_string(),
|
||
target: Some(t.tool.clone()),
|
||
// `path` because the World's SSE reads `detail.path` for this kind
|
||
// and was handed a null on every container-tier call; `input`
|
||
// because the tool name alone cannot answer a single behavioural
|
||
// question about the phase.
|
||
detail: serde_json::json!({
|
||
"path": t.path,
|
||
"input": t.input,
|
||
// Only commands carry one; `bounded_response` returns null for
|
||
// everything else, and a null key here is noise.
|
||
"response": t.response,
|
||
// Null unless a SUBAGENT made this call. It shares its parent's
|
||
// session id, so `agent_id` above names the agent that spawned
|
||
// it and this is the only thing saying the parent did not run
|
||
// it itself.
|
||
"subagent": t.subagent,
|
||
"subagent_id": t.subagent_id,
|
||
}),
|
||
});
|
||
if let Some(path) = &t.path {
|
||
events.push(crate::mission_events::MissionEvent {
|
||
mission_id,
|
||
phase_id: Some(phase_id),
|
||
run_id: Some(run_id),
|
||
agent_id: owner,
|
||
kind: crate::mission_events::FILE_TOUCH.to_string(),
|
||
target: Some(crate::mission_events::repo_relative(
|
||
path,
|
||
&["/mission/repo", "/workspace"],
|
||
)),
|
||
// The ABSOLUTE path as well as the repo-relative one. `target`
|
||
// is normalised for the map, where a `mission` → `repo` pair of
|
||
// directory orbs means nothing to a reader — but normalising is
|
||
// exactly what destroys the question "did this write land
|
||
// outside the checkout", which is the one boundary a skill can
|
||
// be scored on.
|
||
detail: serde_json::json!({ "tool": t.tool, "abs": path }),
|
||
});
|
||
}
|
||
}
|
||
crate::mission_events::record_all(pool, events).await;
|
||
}
|
||
|
||
/// Flip a phase from `pending` to `running`, and say so on the wire.
|
||
///
|
||
/// The `WHERE … AND status = 'pending'` guard means this UPDATE is a claim, not
|
||
/// an assignment: a phase already claimed by another launcher matches nothing.
|
||
/// `RETURNING` is what turns that into information — without it the statement
|
||
/// reports the same `Ok(())` whether it started a phase or lost the race, and
|
||
/// the World would announce a start that never happened.
|
||
///
|
||
/// Five copies of this UPDATE existed, one per launch path. They were identical
|
||
/// and independently maintained, which is how a sixth path would have been
|
||
/// written with no event at all.
|
||
async fn mark_phase_running(pool: &PgPool, mission_id: Uuid, phase_id: Uuid) -> Result<(), String> {
|
||
let claimed = sqlx::query(
|
||
"UPDATE mission_phases
|
||
SET status = 'running', started_at = now()
|
||
WHERE id = $1 AND status = 'pending'
|
||
RETURNING id",
|
||
)
|
||
.bind(phase_id)
|
||
.fetch_optional(pool)
|
||
.await
|
||
.map_err(|e| format!("mark phase {phase_id} running: {e}"))?;
|
||
if claimed.is_some() {
|
||
crate::mission_events::record(
|
||
pool,
|
||
crate::mission_events::MissionEvent::new(
|
||
mission_id,
|
||
crate::mission_events::PHASE_STARTED,
|
||
)
|
||
.phase(phase_id),
|
||
)
|
||
.await;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Close phases whose topology_runs are all terminal.
|
||
///
|
||
/// A phase that declares a `done_when` condition lands in `evaluating` instead
|
||
/// of `completed`; [`evaluate_finished_phases`] judges it and decides whether
|
||
/// to finish or run another pass. A failed run still fails the phase outright
|
||
/// — there is nothing to evaluate — and a phase with no condition completes
|
||
/// exactly as it always did, so untouched missions are unaffected.
|
||
async fn close_finished_phases(pool: &PgPool) -> Result<(), String> {
|
||
let closed = 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')
|
||
)
|
||
RETURNING mp.id, mp.mission_id, mp.status",
|
||
)
|
||
.fetch_all(pool)
|
||
.await
|
||
.map_err(|e| format!("close finished phases: {e}"))?;
|
||
// `RETURNING` rather than a follow-up SELECT, and this is the only way to
|
||
// get these rows: the `CASE` decides each phase's status INSIDE the
|
||
// statement, from `topology_runs` rows whose state the statement itself
|
||
// does not change — so re-deriving it afterwards would be a second
|
||
// implementation of that CASE, free to disagree with the first. Without it
|
||
// this function emits zero `phase.completed` events and reports success.
|
||
for row in closed {
|
||
let phase_id: Uuid = row.get("id");
|
||
let mission_id: Uuid = row.get("mission_id");
|
||
let status: String = row.get("status");
|
||
// `evaluating` is not terminal — the judge has not spoken yet — so it
|
||
// is not a completion. `evaluate_finished_phases` closes those.
|
||
if status == "evaluating" {
|
||
continue;
|
||
}
|
||
crate::mission_events::record(
|
||
pool,
|
||
crate::mission_events::MissionEvent::new(
|
||
mission_id,
|
||
crate::mission_events::PHASE_COMPLETED,
|
||
)
|
||
.phase(phase_id)
|
||
.detail(serde_json::json!({ "status": status })),
|
||
)
|
||
.await;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Judge every phase sitting in `evaluating` against its `done_when`.
|
||
///
|
||
/// Met, or out of iterations → `completed`. Otherwise the phase goes back to
|
||
/// `pending` with `iteration` bumped, and [`start_pending_phases`] relaunches
|
||
/// it; the verdict's reason is carried into the next pass's task text by
|
||
/// [`phase_task_text`] so the agents are told what was missing.
|
||
async fn evaluate_finished_phases(
|
||
pool: &PgPool,
|
||
runtime: &cm_runtime::Runtime,
|
||
) -> Result<(), String> {
|
||
let rows = sqlx::query(
|
||
"SELECT mp.id, mp.mission_id, mp.kind, mp.done_when, mp.max_iterations, mp.iteration,
|
||
m.runtime_kind
|
||
FROM mission_phases mp
|
||
JOIN missions m ON m.id = mp.mission_id
|
||
WHERE mp.status = 'evaluating' AND m.status = 'running'
|
||
AND (mp.judge_retry_after IS NULL OR mp.judge_retry_after <= now())
|
||
LIMIT 5",
|
||
)
|
||
.fetch_all(pool)
|
||
.await
|
||
.map_err(|e| format!("select evaluating phases: {e}"))?;
|
||
|
||
for row in rows {
|
||
let phase_id: Uuid = row.get("id");
|
||
let mission_id: Uuid = row.get("mission_id");
|
||
let kind: String = row.get("kind");
|
||
let condition: String = row
|
||
.get::<Option<String>, _>("done_when")
|
||
.unwrap_or_default();
|
||
let max_iterations: i32 = row.get("max_iterations");
|
||
let iteration: i32 = row.get("iteration");
|
||
let runtime_kind: String = row.get("runtime_kind");
|
||
|
||
// Pull the agent's work onto the host BEFORE judging it.
|
||
//
|
||
// `Sandbox::for_mission` copies from the HOST checkout. In copy mode the
|
||
// agents write inside the container, and their work only reaches the host
|
||
// when `sync_out` runs — which used to happen in the capture sweep AFTER
|
||
// the phase closed. So a phase was judged against a tree that did not yet
|
||
// contain the pass being judged, and the judge truthfully reported
|
||
// nothing there.
|
||
//
|
||
// Mission 01a00cfa is the proof: pass 2 wrote a 434-line
|
||
// IMPLEMENTATION_BRIEF.md whose tests passed and which was pushed to a
|
||
// clean branch, and the verdict on it was
|
||
// "research/IMPLEMENTATION_BRIEF.md does not exist anywhere" — logged
|
||
// one line BEFORE the capture that collected it.
|
||
//
|
||
// Skipped for microVM, whose work is collected out of the guest by
|
||
// `microvm_executor` over this same host path before the VM is
|
||
// destroyed — the identical carve-out the capture sweep makes.
|
||
if crate::mission_fs::copy_mode() && runtime_kind != "microvm" {
|
||
let container = crate::mission_runtime::container_name(mission_id);
|
||
if let Err(e) = crate::mission_fs::sync_out(&container, mission_id).await {
|
||
// Judging now would evaluate a stale tree and, on a last pass,
|
||
// FAIL a phase that succeeded. Leave the phase `evaluating` so
|
||
// the next sweep retries rather than recording a verdict nobody
|
||
// could stand behind.
|
||
eprintln!(
|
||
"phase_runner: could NOT collect work from {container} before judging \
|
||
phase {phase_id} ({e}) — leaving it to the next sweep rather than \
|
||
judging a stale tree"
|
||
);
|
||
continue;
|
||
}
|
||
}
|
||
|
||
let evidence = crate::phase_summarizer::collect_evidence(pool, mission_id, phase_id)
|
||
.await
|
||
.unwrap_or_else(|e| format!("(evidence collection failed: {e})"));
|
||
|
||
let verdict = crate::evaluator::evaluate(runtime, mission_id, &condition, &evidence).await;
|
||
if let Err(e) =
|
||
crate::evaluator::record(pool, mission_id, phase_id, iteration, &verdict).await
|
||
{
|
||
eprintln!("phase_runner: recording evaluation for {phase_id} failed: {e}");
|
||
}
|
||
|
||
// A judge that could not be REACHED has not judged. `Verdict.error` is
|
||
// set only when the evaluator itself failed — "could not judge" as
|
||
// distinct from "judged incomplete" — and spending one of the phase's
|
||
// passes on it charges the agent for an outage it had no part in.
|
||
//
|
||
// Mission 01a011bf lost its script phase exactly this way: the work was
|
||
// done and committed, glm-5.3 returned "transport error: error decoding
|
||
// response body", and with two passes budgeted that single unreachable
|
||
// judge was enough to fail the phase. Leave it `evaluating` so the next
|
||
// sweep re-judges the same pass; the phase is not re-run, only re-read.
|
||
//
|
||
// Deliberately NOT a fallback to the agent's own provider: `evaluate`
|
||
// already refuses that, because a judge from the same family is not an
|
||
// independent check and quietly becoming one is worse than waiting.
|
||
let mut judge_gave_up = false;
|
||
if let Some(why) = verdict.error.as_deref() {
|
||
let blocked_for: Option<f64> = sqlx::query_scalar(
|
||
"UPDATE mission_phases
|
||
SET judge_blocked_since = COALESCE(judge_blocked_since, now())
|
||
WHERE id = $1
|
||
RETURNING EXTRACT(EPOCH FROM now() - judge_blocked_since)::float8",
|
||
)
|
||
.bind(phase_id)
|
||
.fetch_optional(pool)
|
||
.await
|
||
.map_err(|e| format!("mark judge-blocked {phase_id}: {e}"))?
|
||
.flatten();
|
||
|
||
// An exhausted plan names the time it resets. Waiting cannot
|
||
// reach it, so stop now rather than spending the window — and say
|
||
// which of the two very different problems this is.
|
||
if let Some(plain) = judge_error_is_exhausted_plan(why) {
|
||
eprintln!(
|
||
"phase_runner: phase {phase_id} ({kind}) — NOT retrying: {plain}. \
|
||
Pass {} of {} NOT consumed; the agent's work is untouched and the \
|
||
phase is failing on the judge, not on itself.",
|
||
iteration + 1,
|
||
max_iterations,
|
||
);
|
||
judge_gave_up = true;
|
||
} else if blocked_for.unwrap_or(0.0) < JUDGE_WAIT_MAX_SECS {
|
||
let wait = judge_backoff_secs(blocked_for.unwrap_or(0.0));
|
||
let _ = sqlx::query(
|
||
"UPDATE mission_phases
|
||
SET judge_retry_after = now() + make_interval(secs => $2)
|
||
WHERE id = $1",
|
||
)
|
||
.bind(phase_id)
|
||
.bind(wait)
|
||
.execute(pool)
|
||
.await;
|
||
eprintln!(
|
||
"phase_runner: phase {phase_id} ({kind}) — judge unreachable ({why}); \
|
||
retrying in {wait:.0}s. Pass {} of {} NOT consumed; blocked {:.0}s of \
|
||
{JUDGE_WAIT_MAX_SECS:.0}s.",
|
||
iteration + 1,
|
||
max_iterations,
|
||
blocked_for.unwrap_or(0.0)
|
||
);
|
||
continue;
|
||
} else {
|
||
// Waited long enough. Fail with the transport reason rather
|
||
// than sitting `evaluating` forever — an invisible hang is
|
||
// worse than an honest failure that names what could not be
|
||
// reached.
|
||
eprintln!(
|
||
"phase_runner: phase {phase_id} ({kind}) — judge unreachable for {:.0}s, \
|
||
giving up: {why}",
|
||
blocked_for.unwrap_or(0.0)
|
||
);
|
||
// Fail NOW rather than requeueing. Re-running the phase would
|
||
// spend a container and a model budget re-doing work that was
|
||
// never the problem — the judge was.
|
||
judge_gave_up = true;
|
||
}
|
||
} else {
|
||
// A real verdict landed: stop the clock.
|
||
let _ = sqlx::query(
|
||
"UPDATE mission_phases SET judge_blocked_since = NULL, judge_retry_after = NULL
|
||
WHERE id = $1 AND (judge_blocked_since IS NOT NULL
|
||
OR judge_retry_after IS NOT NULL)",
|
||
)
|
||
.bind(phase_id)
|
||
.execute(pool)
|
||
.await;
|
||
}
|
||
|
||
let last_pass = iteration + 1 >= max_iterations;
|
||
if verdict.met || last_pass || judge_gave_up {
|
||
// A phase that ran out of passes WITHOUT meeting its condition did not
|
||
// succeed, and must not say it did. This used to mark both outcomes
|
||
// `completed`: the verdict recorded met=false while the phase — and
|
||
// through `close_finished_missions`, the whole mission — reported
|
||
// success. Anything reading mission status rather than digging into the
|
||
// verdict saw a goal that was never reached as a goal achieved.
|
||
//
|
||
// Found by the Goodhart test for the independent judge: glm-4.7
|
||
// correctly refused a phase whose suite had a failing test, and the
|
||
// mission still closed `completed`.
|
||
let status = if verdict.met { "completed" } else { "failed" };
|
||
let closed = sqlx::query(
|
||
"UPDATE mission_phases SET status = $2, completed_at = now()
|
||
WHERE id = $1 AND status = 'evaluating'
|
||
RETURNING id",
|
||
)
|
||
.bind(phase_id)
|
||
.bind(status)
|
||
.fetch_optional(pool)
|
||
.await
|
||
.map_err(|e| format!("close phase {phase_id}: {e}"))?;
|
||
if closed.is_some() {
|
||
crate::mission_events::record(
|
||
pool,
|
||
crate::mission_events::MissionEvent::new(
|
||
mission_id,
|
||
crate::mission_events::PHASE_COMPLETED,
|
||
)
|
||
.phase(phase_id)
|
||
.detail(serde_json::json!({
|
||
"status": status,
|
||
"judge": verdict.model,
|
||
"reason": verdict.reason,
|
||
})),
|
||
)
|
||
.await;
|
||
}
|
||
eprintln!(
|
||
"phase_runner: phase {phase_id} ({kind}) {status} after {} pass(es) — met={} \
|
||
(judge={}, independent={}) — {}",
|
||
iteration + 1,
|
||
verdict.met,
|
||
verdict.model,
|
||
verdict.independent,
|
||
verdict.reason
|
||
);
|
||
} else {
|
||
sqlx::query(
|
||
"UPDATE mission_phases
|
||
SET status = 'pending', iteration = iteration + 1, started_at = NULL
|
||
WHERE id = $1 AND status = 'evaluating'",
|
||
)
|
||
.bind(phase_id)
|
||
.execute(pool)
|
||
.await
|
||
.map_err(|e| format!("requeue phase {phase_id}: {e}"))?;
|
||
eprintln!(
|
||
"phase_runner: phase {phase_id} ({kind}) not met after pass {} of {max_iterations} — {}",
|
||
iteration + 1,
|
||
verdict.reason
|
||
);
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Close missions whose phases are all terminal.
|
||
/// Mark as `skipped` the phases a failed phase has made unreachable.
|
||
///
|
||
/// `start_pending_phases` launches a phase only when EVERY lower-order phase is
|
||
/// `completed`, so once one fails the phases after it can never run. They sat at
|
||
/// `pending` forever — and `close_finished_missions` requires no phase to be
|
||
/// non-terminal, so the MISSION never finished either. It stayed `running`
|
||
/// indefinitely, which meant `mission_runtime`'s sweeper (which fires N minutes
|
||
/// after a terminal state) never reaped its container.
|
||
///
|
||
/// Found by counting containers, not by a test: gw-04 was holding a runtime
|
||
/// container for a mission whose only run failed three days earlier, phases
|
||
/// `pending,failed`. One leaked container per failed multi-phase mission,
|
||
/// accumulating silently.
|
||
///
|
||
/// `skipped` is not a new concept — `close_finished_missions` already treats it
|
||
/// as terminal, and it is the honest word: those phases were not run and never
|
||
/// will be, which is different from having failed.
|
||
async fn skip_unreachable_phases(pool: &PgPool) -> Result<(), String> {
|
||
let n = sqlx::query(
|
||
"UPDATE mission_phases mp
|
||
SET status = 'skipped', completed_at = now()
|
||
WHERE mp.status = 'pending'
|
||
AND EXISTS (
|
||
SELECT 1 FROM missions m
|
||
WHERE m.id = mp.mission_id AND m.status = 'running'
|
||
)
|
||
-- Strictly EARLIER, because order is what makes a phase
|
||
-- unreachable. A failure later in the list says nothing about a
|
||
-- phase that is still waiting its turn ahead of it.
|
||
AND EXISTS (
|
||
SELECT 1 FROM mission_phases prior
|
||
WHERE prior.mission_id = mp.mission_id
|
||
AND prior.order_idx < mp.order_idx
|
||
AND prior.status = 'failed'
|
||
)",
|
||
)
|
||
.execute(pool)
|
||
.await
|
||
.map_err(|e| format!("skip unreachable phases: {e}"))?
|
||
.rows_affected();
|
||
if n > 0 {
|
||
eprintln!(
|
||
"phase_runner: skipped {n} phase(s) made unreachable by an earlier failure — retrying the failed phase reopens them"
|
||
);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
async fn close_finished_missions(pool: &PgPool) -> Result<(), String> {
|
||
sqlx::query(
|
||
"UPDATE missions m
|
||
SET status =
|
||
CASE
|
||
WHEN EXISTS (
|
||
SELECT 1 FROM mission_phases mp
|
||
WHERE mp.mission_id = m.id AND mp.status = 'failed'
|
||
) THEN 'failed'
|
||
ELSE 'completed'
|
||
END,
|
||
completed_at = now(),
|
||
updated_at = now()
|
||
WHERE m.status = 'running'
|
||
AND EXISTS (SELECT 1 FROM mission_phases mp WHERE mp.mission_id = m.id)
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM mission_phases mp
|
||
WHERE mp.mission_id = m.id
|
||
AND mp.status NOT IN ('completed', 'failed', 'skipped')
|
||
)
|
||
-- A repo-bearing mission must not be declared finished before its
|
||
-- work has been captured. Capture is batched (CAPTURE_BATCH per
|
||
-- tick) and runs AFTER a phase completes, so without this a
|
||
-- backlogged mission closes as 'completed' and only then does
|
||
-- capture discover the phase delivered nothing — leaving a
|
||
-- 'completed' mission holding a 'failed' phase, with the mission
|
||
-- status unfixable because the CASE above only touches 'running'
|
||
-- rows. Waiting a tick costs nothing; the alternative is a mission
|
||
-- whose headline outcome contradicts its own phases.
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM mission_phases mp
|
||
WHERE mp.mission_id = m.id
|
||
AND mp.status = 'completed'
|
||
AND m.repo_id IS NOT NULL
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM mission_artifacts a
|
||
WHERE a.mission_id = mp.mission_id
|
||
AND a.phase_id = mp.id
|
||
AND a.kind = 'code_diff'
|
||
)
|
||
)",
|
||
)
|
||
.execute(pool)
|
||
.await
|
||
.map_err(|e| format!("close finished missions: {e}"))?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Walk the team's graph nodes and bind each to its claw as
|
||
/// `node.attrs["agent"] = "claw_<hex>"`, based on the
|
||
/// `team_members(team_id, node_id, claw_id)` map. Nodes without a
|
||
/// matching member row are left alone (the executor will fall through to
|
||
/// the env alias map / default).
|
||
///
|
||
/// IMPORTANT — the alias MUST live under `attrs`, not at the node's top
|
||
/// level. `cm_topology::graph::Node` only deserializes `{id, role, level,
|
||
/// attrs}`, so a top-level `"agent"` key is silently dropped by serde,
|
||
/// `TurnRequest::agent` comes back `None`, and every turn falls back to
|
||
/// `ZEROCLAW_DEFAULT_AGENT` (`scout`) — which is jailed to scout's own
|
||
/// workspace and cannot see `/mission/repo`. That produced whole missions
|
||
/// of agents burning tokens while reporting an "empty greenfield"
|
||
/// workspace. The top-level key is still written for display/debug, but
|
||
/// `attrs` is what actually binds. See `topology_exec::run_turn`.
|
||
///
|
||
/// The graph shape we care about: `{ "nodes": [ { "id": "n0", ... } ] }`.
|
||
/// Non-object graphs (or graphs without a nodes array) are returned
|
||
/// unchanged.
|
||
async fn inject_node_agents(
|
||
pool: &sqlx::PgPool,
|
||
team_id: Uuid,
|
||
graph: serde_json::Value,
|
||
) -> serde_json::Value {
|
||
let members = match sqlx::query("SELECT node_id, claw_id FROM team_members WHERE team_id = $1")
|
||
.bind(team_id)
|
||
.fetch_all(pool)
|
||
.await
|
||
{
|
||
Ok(rows) => rows,
|
||
Err(e) => {
|
||
eprintln!("phase_runner: load team_members({team_id}) failed: {e}");
|
||
return graph;
|
||
}
|
||
};
|
||
let mut by_node: std::collections::HashMap<String, Uuid> = std::collections::HashMap::new();
|
||
for row in members {
|
||
let node_id: String = row.get("node_id");
|
||
let claw_id: Uuid = row.get("claw_id");
|
||
by_node.insert(node_id, claw_id);
|
||
}
|
||
if by_node.is_empty() {
|
||
return graph;
|
||
}
|
||
apply_node_agents(graph, &by_node)
|
||
}
|
||
|
||
/// Pure core of [`inject_node_agents`] — the DB-free half, so the binding
|
||
/// contract can be regression-tested against the real `TopologyGraph`
|
||
/// deserializer.
|
||
fn apply_node_agents(
|
||
graph: serde_json::Value,
|
||
by_node: &std::collections::HashMap<String, Uuid>,
|
||
) -> serde_json::Value {
|
||
let mut graph = graph;
|
||
if let Some(nodes) = graph.get_mut("nodes").and_then(|v| v.as_array_mut()) {
|
||
for node in nodes {
|
||
let Some(obj) = node.as_object_mut() else {
|
||
continue;
|
||
};
|
||
let id = obj.get("id").and_then(|v| v.as_str()).map(str::to_string);
|
||
let Some(id) = id else { continue };
|
||
if let Some(claw_id) = by_node.get(&id) {
|
||
let alias = crate::runtime_provision::claw_alias(*claw_id);
|
||
// The binding that actually takes effect (see doc comment).
|
||
match obj.get_mut("attrs").and_then(|v| v.as_object_mut()) {
|
||
Some(attrs) => {
|
||
attrs.insert(
|
||
"agent".to_string(),
|
||
serde_json::Value::String(alias.clone()),
|
||
);
|
||
}
|
||
None => {
|
||
let mut attrs = serde_json::Map::new();
|
||
attrs.insert(
|
||
"agent".to_string(),
|
||
serde_json::Value::String(alias.clone()),
|
||
);
|
||
obj.insert("attrs".to_string(), serde_json::Value::Object(attrs));
|
||
}
|
||
}
|
||
// Kept for display/debug only — serde drops it on load.
|
||
obj.insert("agent".to_string(), serde_json::Value::String(alias));
|
||
}
|
||
}
|
||
}
|
||
graph
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
|
||
/// An unreachable judge must not spend the phase's iteration budget, and
|
||
/// must not retry forever either. Both halves are the property: the first
|
||
/// stops an outage failing work that was done, the second stops a dead
|
||
/// validator leaving a phase `evaluating` in silence.
|
||
#[test]
|
||
fn the_judge_wait_is_bounded_and_shorter_than_the_capacity_wait() {
|
||
assert!(
|
||
JUDGE_WAIT_MAX_SECS > 0.0,
|
||
"a zero wait would fail on the first transient error"
|
||
);
|
||
assert!(
|
||
JUDGE_WAIT_MAX_SECS < CAPACITY_WAIT_MAX_SECS,
|
||
"waiting on a judge is cheaper to abandon than waiting on a VM slot"
|
||
);
|
||
// Many sweep ticks, so a blip recovers well inside the window.
|
||
assert!(
|
||
JUDGE_WAIT_MAX_SECS >= 10.0 * 60.0,
|
||
"too short and a normal provider blip fails the phase"
|
||
);
|
||
}
|
||
|
||
/// The give-up path must FAIL, never requeue: re-running the phase spends a
|
||
/// container and a model budget re-doing work that was never the problem.
|
||
/// The real 429 z.ai returns for an exhausted plan. Retrying it is not
|
||
/// optimism, it is arithmetic: on 2026-09-09 the reset was two days out and
|
||
/// the phase spent its whole 30-minute window asking anyway.
|
||
#[test]
|
||
fn an_exhausted_plan_is_recognised_and_names_its_reset() {
|
||
let why = r#"provider returned an error: 429 Too Many Requests: {"type":"error","error":{"type":"rate_limit_error","code":"1310","message":"[1310][Weekly/Monthly Limit Exhausted. Your limit will reset at 2026-09-11 10:01:33][2026090911555755ef730ec0404849]"}}"#;
|
||
let plain = super::judge_error_is_exhausted_plan(why).expect("recognised");
|
||
assert!(plain.contains("2026-09-11 10:01:33"), "{plain}");
|
||
assert!(plain.contains("exhausted"), "{plain}");
|
||
}
|
||
|
||
/// Conservative by design. A transport blip must stay retryable — giving up
|
||
/// on one costs a phase that had done nothing wrong, which is the failure
|
||
/// mission 01a011bf actually suffered.
|
||
#[test]
|
||
fn a_transient_error_stays_retryable() {
|
||
assert!(super::judge_error_is_exhausted_plan(
|
||
"transport error: error decoding response body"
|
||
)
|
||
.is_none());
|
||
assert!(super::judge_error_is_exhausted_plan("429 Too Many Requests").is_none());
|
||
assert!(super::judge_error_is_exhausted_plan("").is_none());
|
||
}
|
||
|
||
/// Waiting as long as we have already waited doubles total elapsed per
|
||
/// attempt, so the window holds ~10 attempts instead of 180.
|
||
#[test]
|
||
fn the_backoff_is_exponential_and_capped() {
|
||
assert_eq!(super::judge_backoff_secs(0.0), 10.0, "first retry is one sweep");
|
||
assert_eq!(super::judge_backoff_secs(20.0), 20.0);
|
||
assert_eq!(super::judge_backoff_secs(160.0), 160.0);
|
||
assert_eq!(
|
||
super::judge_backoff_secs(1_000.0),
|
||
super::JUDGE_RETRY_MAX_BACKOFF_SECS,
|
||
"capped, or a long outage stops retrying at all"
|
||
);
|
||
|
||
// Count the attempts the 30-minute window now allows.
|
||
let mut elapsed = 0.0f64;
|
||
let mut attempts = 1;
|
||
while elapsed < super::JUDGE_WAIT_MAX_SECS {
|
||
elapsed += super::judge_backoff_secs(elapsed);
|
||
attempts += 1;
|
||
}
|
||
assert!(
|
||
(5..=15).contains(&attempts),
|
||
"expected roughly ten attempts, got {attempts}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn giving_up_on_the_judge_closes_the_phase() {
|
||
let src = include_str!("phase_runner.rs");
|
||
let block = src
|
||
.split("let mut judge_gave_up = false;")
|
||
.nth(1)
|
||
.expect("the guard exists");
|
||
let head = &block[..block.find("let last_pass").unwrap_or(block.len())];
|
||
assert!(
|
||
head.contains("judge_gave_up = true;"),
|
||
"the timeout branch must set the flag"
|
||
);
|
||
assert!(
|
||
block.contains("verdict.met || last_pass || judge_gave_up"),
|
||
"the flag must reach the close decision, or it requeues instead"
|
||
);
|
||
}
|
||
|
||
use super::*;
|
||
|
||
fn by_node(pairs: &[(&str, Uuid)]) -> std::collections::HashMap<String, Uuid> {
|
||
pairs.iter().map(|(k, v)| (k.to_string(), *v)).collect()
|
||
}
|
||
|
||
/// Composing the engines must be asked for by name. A mission that says
|
||
/// nothing — or says something adjacent — runs the solo VM path it ran
|
||
/// before, one VM for the whole phase.
|
||
#[test]
|
||
fn only_an_explicit_request_composes_the_engines() {
|
||
assert!(wants_composed(Some("composed")));
|
||
for engine in [
|
||
None,
|
||
Some(""),
|
||
Some("claude_code"),
|
||
Some("zeroclaw"),
|
||
Some("Composed"),
|
||
Some("compose"),
|
||
] {
|
||
assert!(!wants_composed(engine), "{engine:?} must not compose");
|
||
}
|
||
}
|
||
|
||
/// The composed tier the producer inserts and the tier the worker dispatches
|
||
/// on are the same string, and nothing but this test connects them. A typo
|
||
/// would enqueue a row no worker ever claims: the phase would sit `running`
|
||
/// with a `queued` run behind it and no error anywhere.
|
||
#[test]
|
||
fn the_composed_tier_is_one_the_worker_actually_drives() {
|
||
assert!(
|
||
cm_db::repo::topology_runs::WORKER_DRIVEN_TIERS.contains(&"microvm_graph"),
|
||
"the producer would enqueue a run nobody claims"
|
||
);
|
||
}
|
||
|
||
/// The regression from mission `019fcf62`: a coding phase whose agents were
|
||
/// silently unpinned from the repo wrote nothing and reported `completed`,
|
||
/// the same status a fully delivered phase gets.
|
||
#[test]
|
||
fn a_coding_phase_that_delivers_nothing_is_a_failure() {
|
||
let none = serde_json::json!({});
|
||
assert!(
|
||
empty_delivery_is_a_failure("coding", 0, None, &none),
|
||
"zero files from a coding phase is not success"
|
||
);
|
||
assert!(
|
||
!empty_delivery_is_a_failure("coding", 1, None, &none),
|
||
"a phase that changed a file delivered"
|
||
);
|
||
}
|
||
|
||
/// Every phase whose directive tells it to write files is held to it.
|
||
///
|
||
/// This test used to assert the opposite, on the reasoning that "a research
|
||
/// phase legitimately writes nothing to the tree". That was contradicted by
|
||
/// the research directive in this very file, which tells the agent to save
|
||
/// findings under `/mission/repo/research/` — and it cost `benchmark` and
|
||
/// `security_hardening` missions any delivery guarantee at all, because
|
||
/// their defining phases are not `coding`. A `benchmark` mission is ONE
|
||
/// benchmark phase; with that phase exempt, nothing in the platform could
|
||
/// fail it.
|
||
#[test]
|
||
fn every_producing_kind_is_held_to_delivering_files() {
|
||
let none = serde_json::json!({});
|
||
for kind in ["coding", "research", "benchmark", "security_scan"] {
|
||
assert!(
|
||
empty_delivery_is_a_failure(kind, 0, None, &none),
|
||
"{kind} is told to write files, so producing none is a failure"
|
||
);
|
||
assert!(
|
||
!empty_delivery_is_a_failure(kind, 1, None, &none),
|
||
"{kind} that wrote a file delivered"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// A reviewing phase is SUPPOSED to leave the tree alone.
|
||
///
|
||
/// The same distinction `vm_stop_gate::per_node` makes when it drops
|
||
/// `require_changes` for a composed graph's verifier node: holding a
|
||
/// reviewer to changing files fails it for doing exactly its job.
|
||
#[test]
|
||
fn a_reviewing_phase_may_change_nothing() {
|
||
let none = serde_json::json!({});
|
||
for kind in ["review", "something_unrecognised"] {
|
||
assert!(
|
||
!empty_delivery_is_a_failure(kind, 0, None, &none),
|
||
"{kind} is not promised to produce files"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// The distinction this whole defect class turns on: an uncomputable diff
|
||
/// ALSO reports zero files. Blaming the agent for a platform fault would
|
||
/// re-encode the failure as an ordinary outcome — the exact mistake the
|
||
/// `diff_error` field exists to prevent.
|
||
#[test]
|
||
fn an_uncomputable_diff_does_not_fail_the_phase() {
|
||
let none = serde_json::json!({});
|
||
assert!(
|
||
!empty_delivery_is_a_failure("coding", 0, Some("patch: fatal: bad object"), &none),
|
||
"a diff we could not compute is a platform fault, not an empty delivery"
|
||
);
|
||
}
|
||
|
||
/// The escape hatch must be asserted for, not assumed: only an explicit
|
||
/// `true` opts out, so a typo leaves the check armed.
|
||
#[test]
|
||
fn allow_empty_must_be_an_explicit_true() {
|
||
assert!(!empty_delivery_is_a_failure(
|
||
"coding",
|
||
0,
|
||
None,
|
||
&serde_json::json!({"allow_empty": true})
|
||
));
|
||
for wrong in [
|
||
serde_json::json!({"allow_empty": "true"}),
|
||
serde_json::json!({"allow_empty": 1}),
|
||
serde_json::json!({"allow_empty": false}),
|
||
serde_json::json!({"allowEmpty": true}),
|
||
] {
|
||
assert!(
|
||
empty_delivery_is_a_failure("coding", 0, None, &wrong),
|
||
"{wrong} must not disable the check"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// A phase's own task must reach the agent, and two phases of one mission
|
||
/// must not receive identical text.
|
||
///
|
||
/// This is the regression from mission `019fc42b`: `config.task` was
|
||
/// accepted by the API, stored in the DB, and read by nothing. Both coding
|
||
/// phases got byte-identical instructions and both produced the same two
|
||
/// files. Asserting the texts *differ* is the part that matters — asserting
|
||
/// only that the task appears would still pass if the brief carried it.
|
||
#[test]
|
||
fn phase_task_reaches_the_agent_and_distinguishes_phases() {
|
||
let brief = Some("Add two marker files.");
|
||
let alpha = phase_task_text("coding", "Demo", brief, Some("Create ALPHA.md"), true);
|
||
let beta = phase_task_text("coding", "Demo", brief, Some("Create BETA.md"), true);
|
||
|
||
assert!(
|
||
alpha.contains("Create ALPHA.md"),
|
||
"phase task must be injected"
|
||
);
|
||
assert!(beta.contains("Create BETA.md"));
|
||
assert!(
|
||
!alpha.contains("BETA.md"),
|
||
"a phase must not see its sibling's task"
|
||
);
|
||
assert_ne!(
|
||
alpha, beta,
|
||
"sibling phases received identical instructions"
|
||
);
|
||
|
||
// A phase with no task of its own is unchanged from before the fix.
|
||
let bare = phase_task_text("coding", "Demo", brief, None, true);
|
||
assert!(!bare.contains("THIS PHASE'S TASK"));
|
||
// Empty and whitespace-only configs take the same path as absent.
|
||
assert_eq!(
|
||
bare,
|
||
phase_task_text("coding", "Demo", brief, Some(" "), true)
|
||
);
|
||
}
|
||
|
||
/// The marker syntax we hand the agent must be the syntax we parse back.
|
||
///
|
||
/// These two sides used to live far apart — the rules were in team-template
|
||
/// role prompts that mission turns never receive — so nothing caught a
|
||
/// drift between what we asked for and what `task_card_parser` accepts.
|
||
/// Every example line in the prompt is fed through the real parser here.
|
||
#[test]
|
||
fn task_text_marker_examples_parse() {
|
||
let text = phase_task_text("coding", "Demo", Some("brief"), None, true);
|
||
|
||
let examples: Vec<&str> = text
|
||
.lines()
|
||
.map(str::trim)
|
||
.filter(|l| l.starts_with("- ") && l.contains("INT-NN"))
|
||
.map(|l| l.trim_start_matches("- "))
|
||
.collect();
|
||
assert!(
|
||
examples.len() >= 8,
|
||
"expected the full marker ladder in the prompt, found {}: {examples:?}",
|
||
examples.len()
|
||
);
|
||
|
||
for ex in examples {
|
||
// Strip the trailing prose column ("open a new item") and the
|
||
// <placeholder>, leaving a marker line an agent would actually emit.
|
||
let line = ex.replace("INT-NN", "INT-05");
|
||
let line = line.split(" ").next().unwrap_or(&line).trim();
|
||
let line = line
|
||
.replace("<title>", "Add retry")
|
||
.replace("<reason>", "compile error");
|
||
let parsed = crate::task_card_parser::parse(&line);
|
||
assert_eq!(
|
||
parsed.len(),
|
||
1,
|
||
"prompt advertises a marker the parser does not accept: {line:?}"
|
||
);
|
||
assert_eq!(parsed[0].int_id, "INT-05", "wrong id parsed from {line:?}");
|
||
}
|
||
}
|
||
|
||
/// The regression guard: the alias must survive a round-trip through the
|
||
/// real `TopologyGraph` deserializer and land in `attrs`. A top-level
|
||
/// `"agent"` key alone is dropped by serde, which silently routed every
|
||
/// mission turn to the default `scout` agent.
|
||
#[test]
|
||
fn bound_alias_survives_topology_graph_deserialization() {
|
||
let claw = Uuid::nil();
|
||
let graph = serde_json::json!({
|
||
"kind": "pipeline",
|
||
"nodes": [{"id": "n0", "role": "coder"}],
|
||
"edges": [],
|
||
});
|
||
|
||
let out = apply_node_agents(graph, &by_node(&[("n0", claw)]));
|
||
let parsed: cm_topology::TopologyGraph =
|
||
serde_json::from_value(out).expect("graph deserializes");
|
||
|
||
assert_eq!(
|
||
parsed.nodes[0].attrs.get("agent").map(String::as_str),
|
||
Some(crate::runtime_provision::claw_alias(claw).as_str()),
|
||
"alias must be readable from attrs after a real deserialize"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn binding_preserves_existing_attrs() {
|
||
let claw = Uuid::nil();
|
||
let graph = serde_json::json!({
|
||
"kind": "pipeline",
|
||
"nodes": [{"id": "n0", "role": "coder", "attrs": {"budget": "5"}}],
|
||
"edges": [],
|
||
});
|
||
|
||
let out = apply_node_agents(graph, &by_node(&[("n0", claw)]));
|
||
let attrs = &out["nodes"][0]["attrs"];
|
||
assert_eq!(attrs["budget"], "5");
|
||
assert_eq!(attrs["agent"], crate::runtime_provision::claw_alias(claw));
|
||
}
|
||
|
||
#[test]
|
||
fn unmapped_nodes_are_left_unbound() {
|
||
let graph = serde_json::json!({
|
||
"kind": "pipeline",
|
||
"nodes": [{"id": "n0", "role": "coder"}, {"id": "n1", "role": "tester"}],
|
||
"edges": [],
|
||
});
|
||
|
||
let out = apply_node_agents(graph, &by_node(&[("n0", Uuid::nil())]));
|
||
assert!(out["nodes"][0]["attrs"]["agent"].is_string());
|
||
// n1 has no member row — the executor falls back to the alias map.
|
||
assert!(out["nodes"][1].get("attrs").is_none());
|
||
}
|
||
}
|