Files
clawmates/crates/cm-api/src/mission_orchestrator.rs
T
Omar SobhandClaude Opus 5.5 642b6ef44b
deploy / test (push) Successful in 5m35s
deploy / build (push) Successful in 5m48s
feat(self-audit): continuous_improvement audits the project record it can actually reach
Its first run could not do its job. The template audited "every project
agent's .brain" through per-agent APIs — fetch a brain, submit to
/api/claws/{id}/level-up, pull claw metrics — none of which a mission can
reach; agent brains live in the server's /data/brains volume and nothing
delivered them in. It spent itself searching, found a ROSTER.md in a
scratch repo, and audited that.

A delivery channel alone would not have helped: per-mission crews carry
~2 KB seed brains with no history, because missions write memory to the
REPOSITORY brain, one judge verdict per phase. That is where a project's
history actually accumulates, so that is the subject now.

mission_memory::export renders the whole repo brain as markdown — the
.brain is HDF5 and a mission container has no library to read it — and
mission_orchestrator installs it at /mission/memory/PROJECT-MEMORY.md,
outside the checkout so it is input and never lands in the diff, the same
way install_skill_files delivers skills.

The three roles are rewritten for that record: an inspector that finds
patterns (several UNMET lines on the same kind of work) and quotes them;
a proposer that ties each proposal to at least two lines or drops it; and
an evaluator that checks the cited lines exist verbatim and marks each
proposal SUPPORTED, WEAK or UNSUPPORTED. Each says outright that "no
change is warranted" is a complete result — the property that kept the
first run from inventing improvements out of empty brains.

Local: 523 passed; the two DB-backed world tests panic PoolTimedOut
because Docker Desktop is down here. CI runs them against real Postgres.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
2026-09-22 15:27:09 -05:00

1186 lines
52 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Mission-launch orchestrator. Slice 4.
//!
//! When a mission's status flips from `draft` to `running`, this
//! module fires:
//! 1. Materialize a team from `team_template_id` (if set + no
//! `team_id` yet) — inserts agents, ingests brain seeds,
//! records agent_template_link lineage, wires team_members.
//! 2. Bind the resulting `team_id` back onto the mission.
//!
//! Phase execution (running research/coding/benchmark/security_scan
//! against the world) is layered on top by Slices 58.
//!
//! Design notes:
//! - Runtime provisioning is opt-in. Claws are provisioned against the
//! mission's OWN daemon (`RuntimeProvisioner::for_gateway` with the
//! per-mission endpoint), falling back to the global gateway only when
//! there is no per-mission runtime. Provisioning into the global gateway
//! while the run executes on a per-mission daemon leaves that daemon
//! without the `claw_*` agents — it falls back to the default `scout`
//! agent, which cannot see `/mission/repo`.
//! Missing runtime = "insert DB rows only, no live claw" — the
//! mission still boots; live claws land the moment the runtime
//! env is configured + the mission re-launches.
//! - Every step is best-effort logged so a partial materialization
//! can be picked up by a subsequent launch rather than blocking.
use cm_db::repo::team_templates::TeamTemplateDetail;
use cm_domain::{AccessPolicy, Agent, AgentStatus, WorkspaceId};
use sqlx::PgPool;
use uuid::Uuid;
use crate::runtime_provision::RuntimeProvisioner;
/// Called from `routes::missions::set_status` when the transition is
/// draft→running. Materializes the team + returns the new team_id
/// (or an existing one — no-op when the mission already has a team).
///
/// Non-fatal on failure: logs + returns Ok(None) so the mission still
/// becomes `running` and the user can inspect the error via server
/// logs + re-attempt. Later slices surface this on the canvas.
pub async fn on_launch(
pool: &PgPool,
workspace_id: WorkspaceId,
user_id: cm_domain::UserId,
mission_id: Uuid,
node_hub: Option<std::sync::Arc<crate::fleet::NodeHub>>,
blobs: Option<std::sync::Arc<dyn cm_files::BlobStore>>,
) -> Result<Option<Uuid>, String> {
eprintln!("mission_orchestrator::on_launch fired mission_id={mission_id}");
let Some(mission) = cm_db::repo::missions::get(pool, mission_id, workspace_id.as_uuid())
.await
.map_err(|e| format!("load mission: {e}"))?
else {
return Err("mission not found".into());
};
// A Continuous Research mission harvests BEFORE its checkout is taken.
//
// The ORDER here is load-bearing and was wrong: the harvest ran after
// `ensure_checkout`, so the mission cloned the vault before the manifest
// was pushed to it. The reader agent found no harvest.jsonl, and — being
// resourceful — queried arXiv itself and wrote its own. That is precisely
// what `skills/research/arxiv-daily.md` forbids: the papers it found are
// not checked off in `corpus_items`, so the next run re-offers them, and
// the 13 the real harvest DID shelve went unread. Harvest first, then
// clone, so the checkout contains the manifest.
//
// Finding papers is not agent work: `library::run_to_vault` searches arXiv,
// checks the `corpus_items` seen-set, fetches and verifies each PDF, shelves
// it and writes the catalogue note — deterministically, in seconds. The
// seen-set is the entire reason a recurring mission knows what it already
// covered, and an agent re-searching arXiv would leave it wrong.
//
// Deliberately NON-FATAL. A harvest that fails still lets the phases run,
// because the phase is what reports whether today was quiet or broken, and
// those must stay distinguishable. What is never acceptable is silence, so
// both outcomes are logged with their counts.
let mut harvested: Vec<crate::papers::Paper> = Vec::new();
if mission.template_kind == crate::continuous_research::TEMPLATE_KIND {
match blobs.as_ref() {
Some(b) => {
let topics = crate::continuous_research::topics_for(&mission.config);
match crate::continuous_research::harvest_for_mission(
pool,
b,
workspace_id.as_uuid(),
mission_id,
&topics,
5,
)
.await
{
Ok(papers) => {
eprintln!(
"mission_orchestrator: continuous research harvest shelved {} paper(s) for mission {mission_id}",
papers.len()
);
harvested = papers;
}
Err(e) => eprintln!(
"mission_orchestrator: continuous research harvest FAILED for {mission_id} (phases still start, and will report an empty day): {e}"
),
}
}
// Not a warning to bury: without blob storage there is nowhere to
// shelve a PDF, so the mission will find an empty manifest and
// correctly report that nothing arrived.
None => eprintln!(
"mission_orchestrator: mission {mission_id} is continuous_research but blob storage is not configured — no harvest, so today's manifest will be empty"
),
}
}
// ensure_checkout is idempotent (fetch+reset on existing clones,
// clone on missing dirs) so we run it BEFORE the team_id short-
// circuit: a re-launched or retried mission still needs a fresh
// repo checkout even though its team was minted on the first
// launch. Non-fatal — logs and continues on failure.
match crate::mission_workspace::ensure_checkout(pool, workspace_id, mission_id).await {
Ok(Some(path)) => {
eprintln!(
"mission_orchestrator: repo checked out at {} for mission {mission_id}",
path.display()
);
// The manifest goes in the CHECKOUT, not the vault: it is this run's
// input, and the vault path is per-date and shared, so a second run
// the same day rewrites a file that already exists and auto_merge
// rightly refuses the branch. See `write_manifest`.
if mission.template_kind == crate::continuous_research::TEMPLATE_KIND {
let date = crate::continuous_research::today();
// Tag and score each paper before the agents see the list;
// an untriaged manifest (no key) is the old, empty-tags one.
let topics = crate::continuous_research::topics_for(&mission.config);
let triage =
crate::continuous_research::triage_papers(&harvested, &topics).await;
match crate::continuous_research::write_manifest(&path, &harvested, &date, &triage) {
Ok(at) => eprintln!(
"mission_orchestrator: wrote {} paper(s) to {}",
harvested.len(),
at.display()
),
// Loud: the reader phase would find no manifest and, being
// resourceful, go and search arXiv itself — which corrupts
// the seen-set. Better to see why here.
Err(e) => eprintln!(
"mission_orchestrator: could NOT write the harvest manifest for \
{mission_id} — the reader phase will see no papers: {e}"
),
}
}
}
Ok(None) => eprintln!(
"mission_orchestrator: mission {mission_id} has no repo bound, skipping checkout"
),
Err(e) => eprintln!(
"mission_orchestrator: repo checkout for {mission_id} failed (continuing): {e}"
),
}
// Provision the per-mission ZeroClaw runtime container (C3).
// Idempotent: returns the endpoint if the container is already
// running. Falls back silently when docker is unreachable so
// dev-mode + tests still work — the topology_worker will use the
// shared runtime endpoint in that case.
// The mission's own runtime endpoint. Claws MUST be provisioned against
// THIS gateway, not the global one — see RuntimeProvisioner::for_gateway.
let mut mission_gateway: Option<String> = None;
// Not for a microVM mission: the ZeroClaw daemon it would start is never
// spoken to, and it would sit holding a pairing code and ~3 GB of image for
// the life of the mission. Observed doing exactly that on the first real run.
if let Some(prov) = crate::mission_runtime::MissionRuntimeProvisioner::from_env()
.filter(|_| mission.runtime_kind != "microvm")
{
match prov.ensure_container(mission_id).await {
Ok(ec) => {
mission_gateway = Some(ec.endpoint.clone());
crate::container_tool_hooks::record_install(
pool,
mission_id,
None,
ec.hooks.as_deref(),
)
.await;
let container_name = crate::mission_runtime::container_name(mission_id);
if let Err(e) = cm_db::repo::missions::set_runtime_binding(
pool,
mission_id,
workspace_id.as_uuid(),
Some(&container_name),
Some(&ec.endpoint),
ec.pairing_code.as_deref(),
)
.await
{
eprintln!(
"mission_orchestrator: bind runtime container for {mission_id} failed: {e}"
);
} else {
eprintln!(
"mission_orchestrator: runtime container {container_name}{} (paired={}) for mission {mission_id}",
ec.endpoint,
ec.pairing_code.is_some()
);
}
}
Err(e) => eprintln!(
"mission_orchestrator: provision runtime container for {mission_id} failed (continuing with shared runtime): {e}"
),
}
} else {
eprintln!(
"mission_orchestrator: docker unreachable, mission {mission_id} will use shared runtime"
);
}
// microVM PLACEMENT MUST COME BEFORE the early return below. It did not, and
// the first real microvm mission failed with "mission has no target_node_id" —
// the executor's own guard firing correctly on a mission this function had
// returned from before ever choosing a node for it.
// microVM placement. KVM is a hard predicate, not a preference: gw-04 —
// where every mission runs today — is itself a VM without nested
// virtualisation and has no /dev/kvm, so a microvm mission landing there
// cannot start. Resolve a capable node now and fail the launch if there is
// none, because the alternative is a mission that sits in 'running' having
// never had anywhere to run.
if mission.runtime_kind == "microvm" {
// Capable means BOTH: it can host a microVM, and it holds the image this
// mission's backend names. Asking only for `microvm` sent the first real
// microVM mission to a node without `rootfs-claude.ext4`.
let backend = mission.backend.as_deref();
let capable =
cm_db::repo::nodes::online_for_backend(pool, mission.workspace_id, backend)
.await
.map_err(|e| format!("looking up nodes for backend {backend:?}: {e}"))?;
let how_to_fix = format!(
"needs /dev/kvm + firecracker (scripts/fc-node-setup.sh) AND the {} rootfs \
built on that node (scripts/fc-build-rootfs.sh <host> <image> {})",
backend.unwrap_or("default"),
backend.unwrap_or("<name>")
);
let how_to_fix = how_to_fix.as_str();
// CAPABILITY is checked here; CAPACITY is not, and no node is pinned.
//
// Placement moved to phase launch (`phase_runner`). A node chosen now
// would be chosen once, minutes before the first VM boots and hours
// before the last — and re-placing between phases is free, because
// mission state lives on the gateway checkout and every VM is
// inject → run → collect → destroy. Pinning early bought nothing and
// cost the ability to react to a node filling or draining mid-mission.
//
// Launching still FAILS here when no node could ever run this backend:
// that is not transient, waiting will not fix it, and the harness's
// `microvm-negctl` scenario asserts such a mission stays `draft`.
if capable.is_empty() {
return Err(format!(
"no online node can run backend {:?}{how_to_fix}",
backend.unwrap_or("default")
));
}
eprintln!(
"mission_orchestrator: mission {mission_id} has {} node(s) able to run \
backend {:?}; placement happens per phase",
capable.len(),
backend.unwrap_or("default")
);
}
// A microVM mission materialises no team. Its phases run as one `claude -p`
// inside a VM (`microvm_executor`), so there is no claw graph to provision —
// and demanding one rejected the launch of a well-formed mission with "pick
// teams in the wizard". This is the third of three team gates on a path that
// uses no teams; the other two are in `routes::missions` (draft→running) and
// `phase_runner::launch_phase` (no matching teams → stay pending).
//
// Returning before the picks below, not filtering them, because provisioning
// claws that never run is not a cheaper version of the same thing — it is a
// runtime binding and a pairing code describing something nothing uses.
if mission.runtime_kind == "microvm" {
eprintln!(
"mission_orchestrator: mission {mission_id} is a microvm mission — no team to \
materialise; its phases execute in a VM"
);
return Ok(None);
}
// Skip team materialization if already bound.
if mission.team_id.is_some() {
eprintln!(
"mission_orchestrator::on_launch team_id already bound for mission_id={mission_id} — skipping team materialization"
);
return Ok(mission.team_id);
}
// New multi-team model: config.phase_teams = {
// "research": ["template-uuid", ...],
// "coding": ["template-uuid", ...]
// }
// Mints one team per (phase-purpose, template) pair. The FIRST
// minted team gets bound to mission.team_id for backward-compat
// with the single-team surfaces (Team tab, legacy code).
//
// Fallback: if config.phase_teams is absent, use the legacy
// single team_template_id path so existing missions still work.
let phase_teams = mission
.config
.get("phase_teams")
.and_then(|v| v.as_object());
let picks: Vec<(String, Uuid)> = if let Some(pt) = phase_teams {
let mut out = Vec::new();
for (purpose, list) in pt.iter() {
if let Some(arr) = list.as_array() {
for item in arr {
if let Some(id_str) = item.as_str() {
if let Ok(id) = Uuid::parse_str(id_str) {
out.push((purpose.clone(), id));
}
}
}
}
}
out
} else if let Some(id) = mission.team_template_id {
vec![("mission".to_string(), id)]
} else {
return Err(
"mission has no team_template_id and no config.phase_teams — pick teams in the wizard"
.to_string(),
);
};
if picks.is_empty() {
return Err(
"mission's config.phase_teams is empty — pick at least one team in the wizard".into(),
);
}
// Provision into the mission's own daemon when we have one (so the daemon
// that actually runs the turns knows these claws); fall back to the global
// gateway only for dev/no-docker setups where the run uses it too.
let provisioner = match mission_gateway.clone() {
Some(url) => RuntimeProvisioner::for_gateway(url),
None => RuntimeProvisioner::from_env(),
};
// Tell the daemon where the hooks are. `container_tool_hooks::install`
// wrote them; this is what makes claude read them. Doing one without the
// other leaves a gate that is installed and inert, which looks exactly
// like a gate that found nothing.
if let Some(p) = provisioner.as_ref() {
if let Err(e) = p
.set_claude_cli_settings(crate::container_tool_hooks::SETTINGS_PATH)
.await
{
eprintln!(
"mission_orchestrator: could not point claude_cli at the hook \
settings ({e}) — this mission's tool calls run unchecked"
);
}
// Only when this mission got its OWN container — the shared runtime is
// not ours to reconfigure, and `mission_gateway` being Some is exactly
// the signal that `ensure_container` ran.
// What a retrieval arm retrieves FROM is installed here, per arm: the
// MCP door for `index`, the skill files for `files`. `inline` installs
// nothing and `installed` is irrelevant to it.
let requested = crate::skill_delivery::requested_for(&mission.config);
let container = crate::mission_runtime::container_name(mission_id);
let installed = match requested {
_ if mission_gateway.is_none() => false,
crate::skill_delivery::Mode::Index => {
install_skills_door(pool, user_id, mission_id, &container, p).await
}
crate::skill_delivery::Mode::Files => {
install_skill_files(pool, workspace_id, mission_id, &container).await
}
crate::skill_delivery::Mode::Inline => false,
};
// Decided here and recorded, not re-derived per turn: this is the only
// point that knows whether the door actually installed, and an arm that
// could change mid-mission would make the run unattributable.
record_skill_delivery(
pool,
mission_id,
crate::skill_delivery::resolve(requested, installed),
)
.await;
// The repository's whole memory, readable, beside the skills. The
// brief already carries the three most relevant verdicts; this is
// the full record, for work whose subject IS the record — see
// `mission_memory::export` for why it was needed.
if mission_gateway.is_some() {
if let Some(repo) = mission.repo_id {
install_project_memory(repo, mission_id, &container).await;
}
}
}
let mut first_team_id: Option<Uuid> = None;
let mut provisioned_claws: Vec<cm_domain::AgentId> = Vec::new();
for (purpose, template_id) in &picks {
let template = cm_db::repo::team_templates::get(pool, *template_id)
.await
.map_err(|e| format!("load template {template_id}: {e}"))?
.ok_or_else(|| format!("template {template_id} not found"))?;
let team_name = format!(
"{} · {} · {}",
mission.title, purpose, template.template.name
);
let team_id = mint_team_from_template(
TeamMint {
pool,
workspace_id,
user_id,
provisioner: provisioner.as_ref(),
template: &template,
team_name: &team_name,
default_model: MINTED_CLAW_MODEL,
},
&mut provisioned_claws,
)
.await?;
// Record (mission, team, purpose) in mission_teams so the Team
// tab can group by phase purpose without parsing team names.
sqlx::query("INSERT INTO mission_teams (mission_id, team_id, purpose) VALUES ($1, $2, $3)")
.bind(mission_id)
.bind(team_id)
.bind(purpose)
.execute(pool)
.await
.map_err(|e| format!("record mission_team {team_id}: {e}"))?;
if first_team_id.is_none() {
first_team_id = Some(team_id);
}
}
let team_id = first_team_id.expect("picks non-empty guaranteed above");
// Bind the first team onto the mission for legacy single-team paths.
sqlx::query("UPDATE missions SET team_id = $1, updated_at = now() WHERE id = $2")
.bind(team_id)
.bind(mission_id)
.execute(pool)
.await
.map_err(|e| format!("bind team on mission: {e}"))?;
// Pin every provisioned claw's workspace to /mission/repo so
// file_edit / content_search / glob_search / git_operations operate
// on the mission's checked-out repo instead of the empty per-agent
// sandbox. This CANNOT go through the config prop API (workspace.path
// is a PathBuf the prop-schema won't expose — see provision_claw), so
// we patch the shared config file directly on the per-mission runtime
// container. The daemon picks it up on the same reload that surfaces
// the freshly-provisioned claws for the run.
//
// FATAL, deliberately. This was "non-fatal: agents still write (to the
// sandbox) but the committer can't find the changes in /mission/repo" —
// which is to say, the mission runs to completion and delivers nothing.
// Mission `019fcf62` did exactly that: the pin failed with `argument list
// too long`, one line of stderr scrolled past, and phase 0 reported
// `completed` with zero files, no commit error and no push error. A launch
// that cannot bind its agents to the repo has no path to delivering work,
// so it must fail at launch where someone is still looking.
//
// Not for a microVM mission: its agent is a `claude -p` inside a VM on a
// fleet node, not a ZeroClaw claw in a container here, so there is no
// workspace to pin. Leaving it would make a microVM launch FAIL on a
// container it was never going to use.
if !provisioned_claws.is_empty() && mission_gateway.is_some() && mission.runtime_kind != "microvm"
{
if let Some(mp) = crate::mission_runtime::MissionRuntimeProvisioner::from_env() {
mp.pin_agent_workspaces(mission_id, &provisioned_claws, "/mission/repo")
.await
.map_err(|e| {
format!(
"could not pin agent workspaces to /mission/repo ({e}) — the mission \
would run with its agents writing to their sandboxes, delivering nothing"
)
})?;
// The daemon reads config ONCE at boot and never re-reads the
// file, so the pin is invisible until it restarts. Its agents were
// created through its own config API, so they are already
// persisted to the file and survive the restart; the pairing code
// is re-minted on every launch. Equally fatal: an unrestarted
// daemon is an unpinned daemon.
mp.restart_container(mission_id).await.map_err(|e| {
format!("could not restart the runtime to apply the workspace pin: {e}")
})?;
}
}
// Herdr second-runtime: if runtime_kind='local_herdr', spawn a
// pane on target_node running the first available local CLI.
// Non-fatal on failure — the operator sees the error in server
// logs and can manually retry via POST /herdr-dispatch.
if mission.runtime_kind == "local_herdr" {
if let (Some(hub), Some(node_id)) = (node_hub, mission.target_node_id) {
let prompt = mission.description.clone().unwrap_or_default();
// CLI selection: mission.config.cli overrides; else default.
// (Per-template default_cli fallback was in the single-team
// path; the multi-team path doesn't have one canonical
// template to consult, so we keep the mission-level knob.)
let cli = mission
.config
.get("cli")
.and_then(|v| v.as_str())
.map(str::to_string)
.unwrap_or_else(|| "claude".to_string());
match crate::fleet_herdr::dispatch(
hub,
cm_domain::NodeId::from(node_id),
mission_id,
&cli,
&prompt,
)
.await
{
Ok(handle) => eprintln!(
"mission_orchestrator: herdr pane {} spawned on node {}",
handle.pane_id, node_id
),
Err(e) => eprintln!(
"mission_orchestrator: herdr dispatch for {mission_id} failed (continuing): {e}"
),
}
} else {
eprintln!(
"mission_orchestrator: mission {mission_id} is local_herdr but node_hub or target_node missing"
);
}
}
Ok(Some(team_id))
}
/// The read-only inputs for minting a team. Grouped into a struct so the
/// signature stays readable as the orchestrator accumulates context — the
/// growing positional list was also easy to mis-order at the call site,
/// since `team_name` and `default_model` are both `&str`.
struct TeamMint<'a> {
pool: &'a PgPool,
workspace_id: WorkspaceId,
user_id: cm_domain::UserId,
provisioner: Option<&'a RuntimeProvisioner>,
template: &'a TeamTemplateDetail,
team_name: &'a str,
default_model: &'a str,
}
async fn mint_team_from_template(
mint: TeamMint<'_>,
provisioned_claws: &mut Vec<cm_domain::AgentId>,
) -> Result<Uuid, String> {
let TeamMint {
pool,
workspace_id,
user_id,
provisioner,
template,
team_name,
default_model,
} = mint;
// Build the topology graph from role slots so the team's `graph`
// NOT NULL column is satisfied + downstream topology executors
// have a valid shape to iterate over.
let roles: Vec<&str> = template.roles.iter().map(|r| r.slot.as_str()).collect();
let topology_kind = parse_topology_kind(&template.template.default_topology);
let graph = cm_topology::build(topology_kind, &roles)
.map_err(|e| format!("build topology graph: {e}"))?;
let graph_json =
serde_json::to_value(&graph).map_err(|e| format!("serialize topology graph: {e}"))?;
let team_id = Uuid::now_v7();
cm_db::repo::teams::insert_team_with_lifecycle(
pool,
team_id,
workspace_id,
team_name,
&template.template.default_topology,
&graph_json,
"permanent",
)
.await
.map_err(|e| format!("insert team: {e}"))?;
// Stamp template lineage on the team row (Slice 3 columns).
sqlx::query(
"UPDATE teams SET template_id = $1, template_version = $2,
risk_profile = $3, mcp_bundles = $4
WHERE id = $5",
)
.bind(template.template.id)
.bind(template.template.version)
.bind(&template.template.risk_profile)
.bind(serde_json::json!(template.template.mcp_bundles))
.bind(team_id)
.execute(pool)
.await
.map_err(|e| format!("stamp template lineage: {e}"))?;
// Names already on this workspace's roster, so a newly hired claw does not
// arrive sharing a name with someone already here. Read ONCE — a roster
// query per role would be N queries to answer one question — and extended
// locally as we mint, which also keeps names distinct WITHIN this team.
let mut taken_names: Vec<String> = cm_db::repo::agents::roster(pool, workspace_id)
.await
.map_err(|e| format!("read roster for naming: {e}"))?
.into_iter()
.map(|a| a.name)
.collect();
// For each role: create agent, provision runtime, ingest brain
// seed, record link, bind to topology node.
for (idx, role) in template.roles.iter().enumerate() {
// Node id in the graph follows cm_topology's `n0..` convention
// — read the actual node id from the graph so team_members
// stays consistent with the topology.
let Some(node) = graph.nodes.get(idx) else {
return Err(format!(
"topology graph produced {} nodes but template has {} roles",
graph.nodes.len(),
template.roles.len(),
));
};
// Every mission gets its OWN crew.
//
// This deliberately reverses the reuse added earlier. Reuse hired the
// existing claw for a (template, slot) so the roster stayed at one team
// and "My Workforce" was people you keep — but it also meant every
// mission was staffed by the same five names, and the workforce view
// showed one crew repeated down the page with nothing to tell the
// missions apart. Chosen by the operator: distinct crews read better
// than a bounded roster.
//
// The cost is real and is the cost that reuse existed to avoid: claws
// are `lifecycle = 'permanent'` and nothing reaps them until their
// MISSION is deleted, so the roster now grows by the team size on every
// mission. `agent_names::pick` keeps names unique workspace-wide and
// falls back to a numeric suffix once the pool is exhausted, so growth
// degrades the naming gracefully rather than colliding.
//
// `reusable_claw` in cm-db is kept, with its tests: this is a policy
// choice that has now flipped twice, and the query is the hard part.
let reused: Option<uuid::Uuid> = None;
// Seed the name choice from the claw's OWN id, not its position in the
// team.
//
// Seeding with the role index (0..n) started every crew near the top of
// the pool and took the next free names, so the first mission hired
// Aarav, Abebe, Adaora, Adrian, Agnieszka — correct, unique, and
// transparently alphabetical. A crew should look like a team, not like
// a listing. UUIDv7 puts its random bytes LAST (the leading bytes are a
// timestamp, which would cluster again), so the tail is what spreads
// the five picks across the whole pool.
let agent_id = cm_domain::AgentId::new();
let name_seed = {
let uuid = agent_id.as_uuid();
let b = uuid.as_bytes();
u64::from_le_bytes([b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15]])
};
let agent = Agent {
id: agent_id,
workspace_id,
// A PERSON's name, with the role in `job_title`.
//
// This was `"{mission title} · {purpose} · {template} · {slot}"` —
// names like "verify: a repo-less research mission keeps its output
// · mission · Rust SDLC · planner", unreadable in the roster, the
// API and every log line at once. Then it was the bare slot, which
// fixed the length but made the UI show the same word twice (name
// on top, role beneath) and made a roster of five read as five job
// tickets rather than a crew.
//
// The role still lives in `job_title`, which is what the mission
// machinery binds on — `team_members.role_slot` and the topology
// node carry the slot, so nothing downstream keys off the display
// name. Only the reused branch below ignores this, deliberately: a
// claw you already hired keeps the name it already had.
name: crate::agent_names::pick(&taken_names, name_seed),
job_title: role.slot.clone(),
// This is the ONLY consumer of the templates' `system_prompt` prose,
// and it feeds the *chat* path, not missions: it lands in
// `agents.system_prompt`, which `cm_runtime::brain::compose_system`
// uses as the base prompt for a claw's chat turns. A mission turn
// never sees it — `topology_exec::build_prompt` synthesizes its own
// one-line system text from the role slot alone. So deleting the
// template prose to save mission tokens would save exactly zero and
// would leave every mission-minted claw with no identity in chat.
system_prompt: role.system_prompt.clone(),
avatar: String::new(),
accent: default_accent_for(&role.slot).to_string(),
wallpaper: String::new(),
managed_by: user_id,
status: AgentStatus::Online,
};
let claw_id = match reused {
Some(existing) => {
eprintln!(
"mission_orchestrator: reusing claw {existing} for role {} \
(template {})",
role.slot, template.template.id
);
existing
}
None => {
cm_db::repo::agents::insert(pool, &agent, &AccessPolicy::default())
.await
.map_err(|e| format!("insert agent {}: {e}", role.slot))?;
// Claim the name for the rest of this loop. Without this the
// roster snapshot taken before the loop is stale from the
// second role onward and a five-person team can arrive with
// two Merediths.
taken_names.push(agent.name.clone());
agent.id.as_uuid()
}
};
let agent_id = cm_domain::AgentId::from(claw_id);
// The ROLE's model when the template names one, else the mint's default.
// Before migration 0071 there was no role model at all, so every claw of
// every mission team ran the same one — including a reviewer reviewing
// the coder it shares a model with.
let role_model = role
.model
.as_deref()
.map(str::trim)
.filter(|m| !m.is_empty())
.unwrap_or(default_model);
cm_db::repo::agents::set_model_binding(pool, agent_id, role_model)
.await
.map_err(|e| format!("set_model_binding {claw_id}: {e}"))?;
// Runtime provisioning is opt-in — no-op if unconfigured.
// Pass the team template's risk_profile so the claw actually
// gets the tools its role expects (research_readonly for
// scout/researcher, coding_readwrite for coder/tester/committer,
// etc.). Passing "toolfree" — the old default — left every
// agent with zero tools regardless of what its prompt asked for.
//
// Workspace pinning to /mission/repo is NOT done here (the
// config prop-schema can't set workspace.path — see
// provision_claw's doc); the caller pins the collected claws
// out-of-band via MissionRuntimeProvisioner::pin_agent_workspaces.
if let Some(p) = provisioner {
match p
.provision_claw(
claw_id,
role_model,
&template.template.risk_profile,
&template.template.mcp_bundles,
)
.await
{
Ok(_) => provisioned_claws.push(agent_id),
Err(e) => eprintln!(
"mission_orchestrator: provision claw {claw_id} failed (continuing): {e}"
),
}
}
// Ingest brain seed (Slice 3.5d). Non-fatal on failure —
// agent still works from system_prompt alone.
// Seed only a NEW claw. A reused one carries what it learned on earlier
// missions, and re-seeding would overwrite that with the template's
// starting point — which is precisely the accumulation reuse exists for.
if reused.is_none() {
if let Some(seed) = role.brain_seed.as_deref().filter(|s| !s.trim().is_empty()) {
if let Err(e) =
crate::brain_seed::ingest(claw_id, seed.to_string(), role.system_prompt.clone())
.await
{
eprintln!(
"mission_orchestrator: brain_seed ingest for {claw_id} failed (continuing): {e}"
);
}
}
}
// Record lineage (Slice 3.5d) so the MCP skills server can
// merge template default skills with per-agent overrides.
cm_db::repo::agent_template_link::upsert(
pool,
claw_id,
template.template.id,
template.template.version,
&role.slot,
)
.await
.map_err(|e| format!("agent_template_link upsert {claw_id}: {e}"))?;
cm_db::repo::agent_template_link::mark_seeded(pool, claw_id)
.await
.map_err(|e| format!("mark_seeded {claw_id}: {e}"))?;
// Wire team_members using the topology node id.
cm_db::repo::teams::add_member(pool, team_id, &node.id, claw_id, &role.slot)
.await
.map_err(|e| format!("team_members add {claw_id}: {e}"))?;
// Audit for parity with individual claw creation path.
let _ = cm_db::repo::audit::append(
pool,
workspace_id,
cm_db::repo::audit::Actor::User(user_id),
"agent.created",
"agent",
&agent_id.to_string(),
serde_json::json!({
"name": agent.name,
"reused": reused.is_some(),
"job_title": agent.job_title,
"source": "mission_orchestrator",
"template_id": template.template.id.to_string(),
"template_version": template.template.version,
"role_slot": role.slot,
}),
)
.await;
}
Ok(team_id)
}
/// The model a minted claw runs on when its template role does not name one.
///
/// A DEFAULT now, not a hardcode: `template_roles.model` (migration 0071) lets a
/// template put its reviewer on a different model from the coder it reviews,
/// which is the correlated failure the cross-provider judge exists to break,
/// one layer down. Roles that say nothing still land here, so every template
/// that existed before 0071 behaves exactly as it did.
const MINTED_CLAW_MODEL: &str = "claude-sonnet-5";
/// The graph a COMPOSED microVM mission runs, built from its team template
/// without minting a single claw.
///
/// A composed mission needs the template's *shape* — how many nodes, in what
/// pattern, playing what roles — and nothing else it carries. Its nodes are VMs,
/// so provisioning claws for them would create agents, containers and `.brain`
/// files that nothing ever dials; that is exactly why `on_launch` returns early
/// for a microVM mission, and this is how the composed path gets its graph
/// anyway rather than by undoing that.
///
/// `purposes` is the phase's purpose list, matched against `config.phase_teams`;
/// missions using the legacy single `team_template_id` fall back to it.
/// Returns `None` when the mission picked no template at all.
pub async fn composed_graph(
pool: &PgPool,
mission_id: Uuid,
purposes: &[&str],
) -> Result<Option<serde_json::Value>, String> {
let row: Option<(serde_json::Value, Option<Uuid>)> =
sqlx::query_as("SELECT config, team_template_id FROM missions WHERE id = $1")
.bind(mission_id)
.fetch_optional(pool)
.await
.map_err(|e| format!("load mission {mission_id}: {e}"))?;
let Some((config, legacy_template)) = row else {
return Err(format!("mission {mission_id} not found"));
};
// An APPROVED roster wins over the template. It is the more specific answer
// — a model sized it for this mission's actual task and a human accepted it
// — and it is the only path on which nodes carry per-node backends, which is
// how a mission runs more than one provider. Stored already built and
// validated (`routes::mission_roster::decide`), so nothing here can turn a
// refused roster into a running one.
if let Some(roster) = config.get("roster").filter(|v| v.is_object()) {
// Parsed rather than trusted: a graph the orchestrator cannot plan would
// otherwise be claimed and fail as "missing or invalid graph", which
// reads as a runtime fault instead of a bad roster.
serde_json::from_value::<cm_topology::TopologyGraph>(roster.clone())
.map_err(|e| format!("mission {mission_id}: the approved roster is not a runnable topology: {e}"))?;
return Ok(Some(roster.clone()));
}
let template_id = config
.get("phase_teams")
.and_then(|v| v.as_object())
.and_then(|pt| {
// First template named by any purpose this phase answers to, in the
// phase's own preference order — the same order `launch_phase` uses
// to pick teams, so a composed mission and a ZeroClaw one resolve the
// same template for the same phase.
purposes.iter().find_map(|p| {
pt.get(*p)
.and_then(|v| v.as_array())
.and_then(|a| a.first())
.and_then(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok())
})
})
.or(legacy_template);
let Some(template_id) = template_id else {
return Ok(None);
};
let template = cm_db::repo::team_templates::get(pool, template_id)
.await
.map_err(|e| format!("load template {template_id}: {e}"))?
.ok_or_else(|| format!("template {template_id} not found"))?;
let roles: Vec<&str> = template.roles.iter().map(|r| r.slot.as_str()).collect();
if roles.is_empty() {
return Err(format!("template {template_id} defines no roles"));
}
let graph = cm_topology::build(
parse_topology_kind(&template.template.default_topology),
&roles,
)
.map_err(|e| format!("build topology graph for template {template_id}: {e}"))?;
serde_json::to_value(&graph)
.map(Some)
.map_err(|e| format!("serialize topology graph: {e}"))
}
fn parse_topology_kind(s: &str) -> cm_topology::TopologyKind {
use cm_topology::TopologyKind;
match s {
"pipeline" => TopologyKind::Pipeline,
"hierarchical" => TopologyKind::Hierarchical,
"star_moe" => TopologyKind::StarMoe,
_ => TopologyKind::HubSpoke,
}
}
fn default_accent_for(slot: &str) -> &'static str {
match slot {
"planner" | "arch_analyst" | "designer" | "scene_designer" | "api_designer" => "#7cd6e0",
"coder" | "kernel_author" | "shader_author" => "#5fd08a",
"tester" | "bench_engineer" | "perf_engineer" => "#ffb44a",
"reviewer" | "db_engineer" => "#c98af0",
"committer" | "spec_integrator" => "#ff8a7a",
_ => "#8a8a92",
}
}
/// Give this mission's agents a reachable, narrow door to the skills catalogue.
///
/// Two halves that must both happen: the document goes into the container, and
/// the daemon is told to pass it to `claude -p --mcp-config`. Doing one without
/// the other leaves a door that is installed and unreachable, which looks
/// exactly like a door nobody walked through — the same shape as the hooks that
/// were installed and inert.
///
/// # The credential
///
/// A `skills:read` session, not a user's. It is written into a file the agent
/// can `cat` — it runs `Bash` with egress — so the only thing keeping this safe
/// is that the token authenticates to exactly one route and nowhere else. See
/// `cm_auth::AuthService::authenticate_scoped`. A full session here would be an
/// owner-privileged API key handed to something explicitly untrusted, which is
/// why the door went undeployed rather than being deployed the easy way.
///
/// Every failure degrades to "no door", never to a failed launch. A mission
/// that cannot retrieve a skill still delivers.
/// Returns whether the door is installed AND reachable. The caller needs the
/// answer, not just the log line: the `index` delivery arm hands agents a list
/// of uris to fetch, and without a door every one of them is a dead end that
/// reads as an agent ignoring its skills.
async fn install_skills_door(
pool: &PgPool,
user_id: cm_domain::UserId,
mission_id: Uuid,
container: &str,
prov: &RuntimeProvisioner,
) -> bool {
let Some(origin) = crate::container_tool_hooks::api_origin() else {
eprintln!(
"mission_orchestrator: no API origin for the skills door (set \
CLAWMATES_API_ORIGIN) — mission {mission_id} runs without it"
);
return false;
};
// Bound to the mission: revoked by `revoke_mission_credentials` the
// moment it reaches a terminal status. The 24 h TTL is the backstop for a
// mission nothing ever closes, not the credential's lifetime — until
// 2026-09-20 it was, and a twenty-minute mission left a live token in
// its container for the other twenty-three hours.
let auth = cm_auth::AuthService::new(pool.clone());
let token = match auth
.mint_scoped_for_mission(
user_id,
cm_auth::SCOPE_SKILLS_READ,
time::Duration::hours(24),
mission_id,
)
.await
{
Ok(t) => t,
Err(e) => {
eprintln!(
"mission_orchestrator: could not mint a skills token ({e}) — \
mission {mission_id} runs without the door"
);
return false;
}
};
let docker = match crate::container_exec::connect() {
Ok(d) => d,
Err(e) => {
eprintln!("mission_orchestrator: cannot reach docker for the skills door: {e}");
return false;
}
};
let doc = crate::container_tool_hooks::mcp_document(&origin, &token);
let Some(path) = crate::container_tool_hooks::install_door(&docker, container, &doc).await
else {
// `install_door` already said why.
return false;
};
if let Err(e) = prov.set_claude_cli_mcp_config(&path).await {
eprintln!(
"mission_orchestrator: wrote the MCP config but could not point \
claude_cli at it ({e}) — the door is installed and unreachable"
);
return false;
}
eprintln!(
"mission_orchestrator: skills door installed for mission {mission_id} \
({origin}/mcp/skills)"
);
true
}
/// Write every skill the workspace can see into the mission container as a
/// file, for the `files` arm.
///
/// Every visible skill and not only the bound ones, because bindings are
/// resolved per AGENT at turn time (`effective_for_agent`) and this runs once
/// per mission before any turn — the same reason the MCP door serves the whole
/// catalogue rather than a per-mission subset. A few KB each; the whole
/// catalogue is smaller than one phase's evidence.
///
/// Returns whether the files are in place. `false` means the mission falls
/// back to `inline` (see `skill_delivery::resolve`) — an entry that points at
/// a file which is not there reads exactly like an agent ignoring its skills,
/// which is the failure this arm exists to stop misdiagnosing.
async fn install_skill_files(
pool: &PgPool,
workspace_id: WorkspaceId,
mission_id: Uuid,
container: &str,
) -> bool {
let skills = match cm_db::repo::skills_catalog::list_visible(pool, workspace_id.as_uuid()).await
{
Ok(v) => v,
Err(e) => {
eprintln!(
"mission_orchestrator: could not list skills for the files arm ({e}) — \
mission {mission_id} delivers skills inline"
);
return false;
}
};
let docker = match crate::container_exec::connect() {
Ok(d) => d,
Err(e) => {
eprintln!("mission_orchestrator: cannot reach docker for the skill files: {e}");
return false;
}
};
let dir = crate::skill_delivery::SKILLS_DIR;
// `upload_to_container` will not create the directory.
let argv = vec!["sh".to_string(), "-lc".to_string(), format!("mkdir -p {dir}")];
match crate::container_exec::exec_as_root(
&docker,
container,
None,
&argv,
crate::container_tool_hooks::INSTALL_TIMEOUT,
)
.await
{
Ok(out) if out.exit_code == Some(0) => {}
other => {
eprintln!(
"mission_orchestrator: could not create {dir} in {container} ({other:?}) — \
mission {mission_id} delivers skills inline"
);
return false;
}
}
let files: Vec<(String, Vec<u8>)> = skills
.iter()
.map(|sk| (format!("{}.md", sk.name), sk.body.clone().into_bytes()))
.collect();
let n = files.len();
if let Err(e) = crate::mission_fs::put_files(&docker, container, dir, &files).await {
eprintln!(
"mission_orchestrator: could not write the skill files ({e}) — mission \
{mission_id} delivers skills inline"
);
return false;
}
eprintln!("mission_orchestrator: {n} skill file(s) installed for mission {mission_id} under {dir}");
true
}
/// Write the repository's memory export into the mission container.
///
/// Best-effort and loud: a mission with no memory to read is an ordinary
/// mission, and a first mission on a repository has none. Outside the
/// checkout (`mission_memory::MEMORY_DIR`) so it never lands in the diff.
async fn install_project_memory(repo_id: Uuid, mission_id: Uuid, container: &str) {
let Some(md) = crate::mission_memory::export(repo_id) else {
return;
};
let docker = match crate::container_exec::connect() {
Ok(d) => d,
Err(e) => {
eprintln!("mission_orchestrator: cannot reach docker for project memory: {e}");
return;
}
};
let dir = crate::mission_memory::MEMORY_DIR;
let argv = vec!["sh".to_string(), "-lc".to_string(), format!("mkdir -p {dir}")];
if !matches!(
crate::container_exec::exec_as_root(
&docker,
container,
None,
&argv,
crate::container_tool_hooks::INSTALL_TIMEOUT,
)
.await,
Ok(out) if out.exit_code == Some(0)
) {
eprintln!("mission_orchestrator: could not create {dir} for mission {mission_id}");
return;
}
let bytes = md.len();
let files = vec![(crate::mission_memory::MEMORY_FILE.to_string(), md.into_bytes())];
match crate::mission_fs::put_files(&docker, container, dir, &files).await {
Ok(()) => eprintln!(
"mission_orchestrator: project memory ({bytes} bytes) installed for mission \
{mission_id} at {dir}/{}",
crate::mission_memory::MEMORY_FILE
),
Err(e) => eprintln!(
"mission_orchestrator: could not write project memory for {mission_id}: {e}"
),
}
}
/// Record which arm this mission runs, so every turn composes the same one and
/// the score can be attributed to it afterwards.
///
/// A write failure is not fatal: `skill_delivery_mode` reads NULL as `inline`,
/// which is the arm that needs nothing installed. A mission that quietly ran
/// the control arm is a lost data point; a mission that failed to launch over
/// a telemetry column is a lost mission.
async fn record_skill_delivery(pool: &PgPool, mission_id: Uuid, mode: crate::skill_delivery::Mode) {
if let Err(e) = sqlx::query("UPDATE missions SET skill_delivery = $2 WHERE id = $1")
.bind(mission_id)
.bind(mode.as_str())
.execute(pool)
.await
{
eprintln!(
"mission_orchestrator: could not record skill_delivery={} for mission \
{mission_id} ({e}) — its turns will compose skills inline",
mode.as_str()
);
}
}
/// Revoke every credential minted for a mission. Called on every path that
/// takes a mission to a terminal status — the runner's close and the
/// operator's stop — so the authority a mission was given ends with it.
/// Best-effort and loud: a revocation that failed is logged with the count
/// it could not clear, which is the number an operator needs.
pub async fn revoke_mission_credentials(pool: &PgPool, mission_id: Uuid) {
match cm_auth::AuthService::new(pool.clone())
.revoke_mission_sessions(mission_id)
.await
{
Ok(0) => {}
Ok(n) => eprintln!(
"mission_orchestrator: revoked {n} credential(s) for mission {mission_id} at close"
),
Err(e) => eprintln!(
"mission_orchestrator: could NOT revoke credentials for mission {mission_id}: {e} \
— they expire on their own within 24 h"
),
}
}