missions: hard-require team template — block empty-team launches
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:
@@ -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)
|
||||||
|
|||||||
@@ -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)?;
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
Reference in New Issue
Block a user