missions: hard-require team template — block empty-team launches
ci / gates (push) Successful in 6s
ci / frontend (push) Successful in 37s
ci / rust (push) Successful in 3m6s
ci / e2e (push) Skipped
ci / publish (push) Successful in 4m4s

Root-cause fix for the "mission runs with zero agents" bug. Three
enforcement layers now guarantee a launched mission has a team:

  1. mission_orchestrator::on_launch — the previous
     \`return Ok(None)\` when both team_id and team_template_id are
     None is now \`return Err(...)\`. That branch was never a real
     "auto-provision later" path; it was a silent no-op that let
     the mission flip to running with nothing to run.
  2. routes::missions::set_status — the draft→running transition
     now (a) rejects with 400 when team_id + team_template_id are
     both null, and (b) runs on_launch BEFORE flipping status +
     returns 500 on failure. No more orphan "running" missions
     with no materialization.
  3. MissionWizard step 3 — removed the misleading "LLM
     auto-provision" tile (fake code path). First real template is
     pre-selected on mount; canNext requires teamTemplateId set;
     empty state surfaces a red warning if no templates loaded.
  4. MissionCanvas Launch button — disabled with a "No team" label
     and explanatory tooltip when the mission has neither team_id
     nor team_template_id (defense-in-depth for legacy rows or
     direct-API missions).

Also flipped the mission_orchestrator test that expected
Ok(None) → now expects a specific error message.

Prod cleanup: reset the stuck mission
019f814c-d36f-7d60-8915-1ce100683133 (running with team_id=NULL) back
to draft so the operator can delete or attach a template.

Verified: cargo check --workspace + tsc + eslint all green;
mission_orchestrator test updated to match new contract.
This commit is contained in:
Omar Sobh
2026-07-20 15:50:24 -07:00
parent 3ba0485e7d
commit 0ee689f590
5 changed files with 85 additions and 47 deletions
+9 -4
View File
@@ -51,10 +51,15 @@ pub async fn on_launch(
return Ok(mission.team_id); return Ok(mission.team_id);
} }
let Some(template_id) = mission.team_template_id else { let Some(template_id) = mission.team_template_id else {
// No template + no team = phase execution will auto-provision // Hard fail — a mission with no team AND no template can't run:
// via the LLM path (Slice 2's fallback), or run against the // there are no agents to execute phases. The wizard requires
// shared runtime. Nothing to do here. // a template pick; this branch guards against direct API
return Ok(None); // callers or legacy rows.
return Err(
"mission has no team_id and no team_template_id — pick a template in the wizard \
before launching, or attach an existing team via the API"
.to_string(),
);
}; };
let template = cm_db::repo::team_templates::get(pool, template_id) let template = cm_db::repo::team_templates::get(pool, template_id)
+11 -3
View File
@@ -442,10 +442,14 @@ pub async fn set_status(
.await? .await?
.ok_or(ApiError::NotFound)?; .ok_or(ApiError::NotFound)?;
cm_db::repo::missions::set_status(&state.pool, id, user.workspace_id.as_uuid(), &body.status) // Draft→running requires a materializable team. Run the orchestrator
.await?; // BEFORE flipping status so a materialization failure keeps the
// mission in draft (no orphaned "running" mission with no agents).
if prior.status == "draft" && body.status == "running" { if prior.status == "draft" && body.status == "running" {
if prior.team_id.is_none() && prior.team_template_id.is_none() {
eprintln!("mission {id}: launch rejected — no team_id and no team_template_id");
return Err(ApiError::BadRequest);
}
if let Err(e) = crate::mission_orchestrator::on_launch( if let Err(e) = crate::mission_orchestrator::on_launch(
&state.pool, &state.pool,
user.workspace_id, user.workspace_id,
@@ -456,9 +460,13 @@ pub async fn set_status(
.await .await
{ {
eprintln!("mission {id}: on_launch failed: {e}"); eprintln!("mission {id}: on_launch failed: {e}");
return Err(ApiError::Internal);
} }
} }
cm_db::repo::missions::set_status(&state.pool, id, user.workspace_id.as_uuid(), &body.status)
.await?;
let mission = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid()) let mission = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid())
.await? .await?
.ok_or(ApiError::NotFound)?; .ok_or(ApiError::NotFound)?;
+8 -6
View File
@@ -223,7 +223,7 @@ async fn on_launch_is_idempotent() {
} }
#[tokio::test] #[tokio::test]
async fn on_launch_no_template_returns_none() { async fn on_launch_no_template_hard_fails() {
let pool = cm_testkit::test_pool().await; let pool = cm_testkit::test_pool().await;
let ws = seed_workspace(&pool).await; let ws = seed_workspace(&pool).await;
let owner = seed_owner(&pool, ws).await; let owner = seed_owner(&pool, ws).await;
@@ -242,12 +242,14 @@ async fn on_launch_no_template_returns_none() {
.await .await
.unwrap(); .unwrap();
let result = mission_orchestrator::on_launch(&pool, ws, owner, mission_id, None) let result = mission_orchestrator::on_launch(&pool, ws, owner, mission_id, None).await;
.await let err = result.expect_err("no template + no team must be a hard error");
.unwrap(); assert!(
assert!(result.is_none(), "no template + no team should return None"); err.contains("no team_id and no team_template_id"),
"unexpected error message: {err}"
);
// Mission stays with team_id NULL. // Mission stays with team_id NULL — no partial materialization.
let team_id: Option<Uuid> = sqlx::query_scalar("SELECT team_id FROM missions WHERE id = $1") let team_id: Option<Uuid> = sqlx::query_scalar("SELECT team_id FROM missions WHERE id = $1")
.bind(mission_id) .bind(mission_id)
.fetch_one(&pool) .fetch_one(&pool)
@@ -410,20 +410,35 @@ export function MissionCanvas({
> >
<RefreshCw size={13} /> <RefreshCw size={13} />
</button> </button>
{mission.status === "draft" && ( {mission.status === "draft" && (() => {
const hasTeam =
mission.team_id !== null || mission.team_template_id !== null;
const disabled = launching || !hasTeam;
return (
<button <button
type="button" type="button"
onClick={launch} onClick={launch}
disabled={launching} disabled={disabled}
title={
!hasTeam
? "Cannot launch: mission has no team + no team template. Delete and recreate with a template picked in step 3, or attach a team via the API."
: "Launch this mission"
}
style={{ style={{
...primaryBtn, ...primaryBtn,
opacity: launching ? 0.5 : 1, opacity: disabled ? 0.5 : 1,
cursor: disabled ? "not-allowed" : "pointer",
}} }}
> >
<Play size={13} style={{ marginRight: 4 }} /> <Play size={13} style={{ marginRight: 4 }} />
{launching ? "Launching…" : "Launch"} {launching
? "Launching…"
: hasTeam
? "Launch"
: "No team"}
</button> </button>
)} );
})()}
</div> </div>
</div> </div>
<h1 style={{ margin: 0, fontSize: 20, color: "#f3f3f5" }}>{mission.title}</h1> <h1 style={{ margin: 0, fontSize: 20, color: "#f3f3f5" }}>{mission.title}</h1>
@@ -51,9 +51,16 @@ export function MissionWizard({
useEffect(() => { useEffect(() => {
(async () => { (async () => {
try { try {
setTeamTemplates(await listTeamTemplates()); const list = await listTeamTemplates();
setTeamTemplates(list);
// Pre-select the first template so the wizard has a
// valid selection by default — a mission without a team
// can't run, and the backend now rejects that transition
// hard, so the empty default was actively wrong.
// eslint-disable-next-line react-hooks/set-state-in-effect
if (list.length > 0) setTeamTemplateId((prev) => prev || list[0].id);
} catch { } catch {
// Non-fatal: user can still create a mission without a team template. // Non-fatal: user is stuck on step 3 until templates load.
} }
})(); })();
}, []); }, []);
@@ -95,7 +102,7 @@ export function MissionWizard({
(step === 2 && (step === 2 &&
title.trim().length > 0 && title.trim().length > 0 &&
(!preset.requiresRepo || repo !== null)) || (!preset.requiresRepo || repo !== null)) ||
step === 3 || (step === 3 && teamTemplateId !== "") ||
(step === 4 && (step === 4 &&
(runtimeKind === "zeroclaw" || targetNodeId !== "")); (runtimeKind === "zeroclaw" || targetNodeId !== ""));
@@ -309,29 +316,30 @@ export function MissionWizard({
{step === 3 && ( {step === 3 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}> <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<span style={labelStyle}>Team template</span> <span style={labelStyle}>Team template *</span>
<p style={hintStyle}> <p style={hintStyle}>
Pick a canonical roster. On mission launch the team is Pick a canonical roster. On mission launch the team is
materialized from the template — roles, prompts, MCP bundles, materialized — roles, prompts, MCP bundles, skills, brain
and (later) skills + brain seeds. Leave unset to let the seeds. Required: a mission with no team has no agents to
mission auto-provision an LLM-derived team from the prompt. run it. If none of these fit, extend the templates catalog.
</p> </p>
<div style={{ display: "grid", gap: 8 }}> {teamTemplates.length === 0 && (
<button <div
type="button"
onClick={() => setTeamTemplateId("")}
style={{ style={{
...templateCardStyle(teamTemplateId === ""), padding: 14,
borderRadius: 10,
border: "1px solid rgba(255,138,122,.4)",
background: "rgba(255,138,122,.08)",
color: "#ff8a7a",
fontSize: 12.5,
}} }}
> >
<span style={{ fontWeight: 700, color: "#f3f3f5" }}> No team templates available. Check that the server has
LLM auto-provision loaded templates from templates/teams/ — logs should
</span> say `team_template_loader: upserted builtin ...`.
<span style={{ fontSize: 12, color: "#a0a0a8" }}> </div>
Let Claude Sonnet 5 derive 3–5 roles from the description. )}
Best for one-off or exploratory missions. <div style={{ display: "grid", gap: 8 }}>
</span>
</button>
{teamTemplates.map((t) => { {teamTemplates.map((t) => {
const active = teamTemplateId === t.id; const active = teamTemplateId === t.id;
return ( return (