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
1186 lines
52 KiB
Rust
1186 lines
52 KiB
Rust
//! 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 5–8.
|
||
//!
|
||
//! 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"
|
||
),
|
||
}
|
||
}
|