//! 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 via `RuntimeProvisioner::from_env`. //! 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>, ) -> Result, String> { 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()); }; // Skip if already bound. if mission.team_id.is_some() { return Ok(mission.team_id); } let Some(template_id) = mission.team_template_id else { // No template + no team = phase execution will auto-provision // via the LLM path (Slice 2's fallback), or run against the // shared runtime. Nothing to do here. return Ok(None); }; let template = cm_db::repo::team_templates::get(pool, template_id) .await .map_err(|e| format!("load template: {e}"))? .ok_or_else(|| format!("template {template_id} not found"))?; let provisioner = RuntimeProvisioner::from_env(); let team_id = mint_team_from_template( pool, workspace_id, user_id, provisioner.as_ref(), &template, &mission.title, "claude-sonnet-5", ) .await?; // Bind the team onto the mission. 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}"))?; // Ensure the mission's repo is checked out at // $CLAWMATES_MISSIONS_ROOT/{mission_id}/repo — where // security_scan + benchmark_runner exec against. Non-fatal: // missions without a repo (research_only, custom) skip cleanly, // and clone failures log without blocking launch (the operator // sees the error on the canvas via the run's failed status when // a repo-dependent phase tries to fire). match crate::mission_workspace::ensure_checkout(pool, workspace_id, mission_id).await { Ok(Some(path)) => eprintln!( "mission_orchestrator: repo checked out at {}", path.display() ), Ok(None) => {} Err(e) => eprintln!( "mission_orchestrator: repo checkout for {mission_id} failed (continuing): {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 precedence: // mission.config.cli → template.config.default_cli → "claude" // Templates encode which agent CLI fits their stack; missions can // override per-run for A/B (kimi on morpheus vs claude on tank). let cli = mission .config .get("cli") .and_then(|v| v.as_str()) .map(str::to_string) .or_else(|| { template .template .config .get("default_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)) } async fn mint_team_from_template( pool: &PgPool, workspace_id: WorkspaceId, user_id: cm_domain::UserId, provisioner: Option<&RuntimeProvisioner>, template: &TeamTemplateDetail, team_name: &str, default_model: &str, ) -> Result { // 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}"))?; // 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(), )); }; let agent = Agent { id: cm_domain::AgentId::new(), workspace_id, name: format!("{} · {}", team_name, role.slot), job_title: role.slot.clone(), 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, }; cm_db::repo::agents::insert(pool, &agent, &AccessPolicy::default()) .await .map_err(|e| format!("insert agent {}: {e}", role.slot))?; let claw_id = agent.id.as_uuid(); cm_db::repo::agents::set_model_binding(pool, agent.id, default_model) .await .map_err(|e| format!("set_model_binding {claw_id}: {e}"))?; // Runtime provisioning is opt-in — no-op if unconfigured. if let Some(p) = provisioner { if let Err(e) = p.provision_claw(claw_id, default_model).await { 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. 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, "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) } 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", } }