The card shipped in e20b321 could not actually be used. Three things were
missing, each of which failed at a different distance from its cause.
**1. `default_team_template` was parsed and never read.** Every recipe declares
one; `WorkflowRecipe` carries the field; nothing consumed it. A mission created
from a card with no explicitly chosen team was rejected at LAUNCH with "no
team_id, no team_template_id, no config.phase_teams" — one step removed from the
real cause, which is that creation ignored the recipe. Create now resolves it
via `team_templates::get_by_key`, only when the caller named no team of any
kind, so an explicit choice still wins. A test asserts every shipped recipe
names a template that has a `templates/teams/<key>.toml`, because a mismatch
there produces an unlaunchable card.
**2. The harvest ran nowhere.** `harvest_for_mission` existed and nothing called
it. `on_launch` now runs it for `continuous_research` missions, before the
phases start, and threads the blob store through from `main` (the route already
had it on `AppState`; the scheduler needed it). Deliberately non-fatal: a
harvest that fails still starts the phases, because the phase is what reports
whether today was quiet or broken and those must stay distinguishable — but
never silent, so both outcomes log their counts.
**3. Nothing wrote the manifest.** `templates/teams/continuous_research.toml`
has pointed its reader role at `ContinuousResearch/<date>/harvest.jsonl` since it
was authored, and the file did not exist — agents aimed at a path nothing
produced. `run_to_vault` now writes it beside the notes and stages it, but only
for a mission-attributed run. `Harvest` carries the shelved `Paper`s to build
it; re-parsing the notes we had just written would have been a parse of our own
output and one more place for the two to drift.
Also: the blob root. `storage.data_dir` defaults to "./data" and the container's
cwd is `/`, so the server tried to create `/data` as uid 65532 and EVERY shelve
failed with "storage io: Permission denied". The image now creates
/var/lib/clawmates-blobs owned by 65532 so a mounted volume inherits it rather
than arriving root:root. Kept off /var/lib/clawmates-missions on purpose: that
tree is swept, and a paper shelved there would be deleted out from under its own
catalogue note.
Proven end to end on a real mission: 15 candidates, 2 already held, 13 shelved,
0 failed; branch auto-merged as additive-only; manifest on vault `main` with
every documented key. The "already held" counts are the seen-set deduping across
topics within a single run, which is the behaviour the whole design exists for.
The project brief now comes from the mission description — `phase_task_text`
already places it under BRIEF verbatim, so no new field was needed.
346 tests pass.
Co-Authored-By: Claude Opus 5 <[email protected]>
377 lines
13 KiB
Rust
377 lines
13 KiB
Rust
//! End-to-end coverage for `mission_orchestrator::on_launch` — the
|
|
//! draft→running transition that materializes a team from a template,
|
|
//! inserts agents, seeds brains, records template lineage, and binds
|
|
//! members via team_members. Runs against a real Postgres (cm-testkit).
|
|
//!
|
|
//! What this test locks in:
|
|
//! * Given a mission with `team_template_id` and no `team_id`,
|
|
//! `on_launch` materializes exactly one team per role in the
|
|
//! template.
|
|
//! * The materialized team carries `template_id` + `template_version`
|
|
//! + `risk_profile` + `mcp_bundles` from the template row.
|
|
//! * Every role produces one `agents` row + one
|
|
//! `agent_template_link` row (seeded=true).
|
|
//! * `team_members` binds every claw to a topology node id.
|
|
//! * The mission row's `team_id` gets updated.
|
|
//! * Re-invoking is a no-op (returns the existing team_id, doesn't
|
|
//! duplicate agents).
|
|
|
|
use cm_api::mission_orchestrator;
|
|
use cm_db::repo::{team_templates, users, workspaces};
|
|
use cm_domain::{Role, User, UserId, Workspace, WorkspaceId};
|
|
use serde_json::json;
|
|
use sqlx::Row;
|
|
use uuid::Uuid;
|
|
|
|
async fn seed_workspace(pool: &sqlx::PgPool) -> WorkspaceId {
|
|
let ws = Workspace {
|
|
id: WorkspaceId::new(),
|
|
name: "Missions Test".into(),
|
|
plan: "team".into(),
|
|
};
|
|
workspaces::insert(pool, &ws).await.unwrap();
|
|
ws.id
|
|
}
|
|
|
|
async fn seed_owner(pool: &sqlx::PgPool, ws: WorkspaceId) -> UserId {
|
|
let user = User {
|
|
id: UserId::new(),
|
|
workspace_id: ws,
|
|
email: "[email protected]".into(),
|
|
role: Role::Owner,
|
|
display_name: "Owner".into(),
|
|
created_at: time::OffsetDateTime::UNIX_EPOCH,
|
|
};
|
|
users::insert(pool, &user).await.unwrap();
|
|
user.id
|
|
}
|
|
|
|
async fn seed_test_template(pool: &sqlx::PgPool) -> Uuid {
|
|
let id = Uuid::now_v7();
|
|
team_templates::upsert_builtin(
|
|
pool,
|
|
team_templates::UpsertBuiltin {
|
|
id,
|
|
key: "test_backend",
|
|
name: "Test Backend Team",
|
|
stack: vec!["rust".into(), "postgres".into()],
|
|
default_topology: "pipeline",
|
|
risk_profile: "medium",
|
|
mcp_bundles: vec!["clawmates_skills".into()],
|
|
version: 1,
|
|
description: Some("Fixture template for orchestrator test"),
|
|
config: json!({}),
|
|
category: "development",
|
|
roles: vec![
|
|
team_templates::UpsertBuiltinRole {
|
|
slot: "planner",
|
|
order_idx: 0,
|
|
system_prompt: "Plan the feature. Break it into INT-XX items.",
|
|
skills: vec!["decompose-int-items".into()],
|
|
brain_seed: Some("# Planner\nBreak features into INT items."),
|
|
model: None,
|
|
},
|
|
team_templates::UpsertBuiltinRole {
|
|
slot: "coder",
|
|
order_idx: 1,
|
|
system_prompt: "Implement one INT item at a time.",
|
|
skills: vec!["write-rust-current-edition".into()],
|
|
brain_seed: Some("# Coder\nOne INT per commit."),
|
|
model: None,
|
|
},
|
|
team_templates::UpsertBuiltinRole {
|
|
slot: "reviewer",
|
|
order_idx: 2,
|
|
system_prompt: "Review each commit before merge.",
|
|
skills: vec!["code-review-checklist".into()],
|
|
brain_seed: None,
|
|
// The point of migration 0071: a reviewer that does NOT
|
|
// share a model with the coder it reviews.
|
|
model: Some("glm-4.7"),
|
|
},
|
|
],
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
id
|
|
}
|
|
|
|
async fn seed_mission(
|
|
pool: &sqlx::PgPool,
|
|
ws: WorkspaceId,
|
|
template_id: Uuid,
|
|
title: &str,
|
|
) -> Uuid {
|
|
let id = Uuid::now_v7();
|
|
sqlx::query(
|
|
"INSERT INTO missions
|
|
(id, workspace_id, title, template_kind, team_template_id, schedule, status, config)
|
|
VALUES ($1, $2, $3, 'research_and_code', $4, '{\"kind\":\"one_shot\"}'::jsonb,
|
|
'draft', '{}'::jsonb)",
|
|
)
|
|
.bind(id)
|
|
.bind(ws.as_uuid())
|
|
.bind(title)
|
|
.bind(template_id)
|
|
.execute(pool)
|
|
.await
|
|
.unwrap();
|
|
id
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn on_launch_materializes_team_from_template() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let ws = seed_workspace(&pool).await;
|
|
let owner = seed_owner(&pool, ws).await;
|
|
let template_id = seed_test_template(&pool).await;
|
|
let mission_id = seed_mission(&pool, ws, template_id, "Test Mission").await;
|
|
|
|
let team_id = mission_orchestrator::on_launch(&pool, ws, owner, mission_id, None, None)
|
|
.await
|
|
.expect("on_launch succeeds")
|
|
.expect("returns a team id");
|
|
|
|
// Team row exists with template lineage stamped.
|
|
let team_row = sqlx::query(
|
|
"SELECT template_id, template_version, risk_profile, mcp_bundles
|
|
FROM teams WHERE id = $1",
|
|
)
|
|
.bind(team_id)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
let stamped_template_id: Uuid = team_row.get("template_id");
|
|
let stamped_version: i32 = team_row.get("template_version");
|
|
let stamped_risk: String = team_row.get("risk_profile");
|
|
assert_eq!(stamped_template_id, template_id);
|
|
assert_eq!(stamped_version, 1);
|
|
assert_eq!(stamped_risk, "medium");
|
|
|
|
// One agent per role — three total.
|
|
let agent_count: i64 =
|
|
sqlx::query_scalar("SELECT count(*)::bigint FROM team_members WHERE team_id = $1")
|
|
.bind(team_id)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(agent_count, 3, "expected one member per role");
|
|
|
|
// Every member has a matching agents row + role_slot binding.
|
|
let member_slots: Vec<String> = sqlx::query_scalar(
|
|
"SELECT tm.role FROM team_members tm
|
|
JOIN agents a ON a.id = tm.claw_id
|
|
WHERE tm.team_id = $1
|
|
ORDER BY tm.role",
|
|
)
|
|
.bind(team_id)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(member_slots, vec!["coder", "planner", "reviewer"]);
|
|
|
|
// Every claw has an agent_template_link row with seeded=true.
|
|
let seeded_count: i64 = sqlx::query_scalar(
|
|
"SELECT count(*)::bigint FROM agent_template_link atl
|
|
JOIN team_members tm ON tm.claw_id = atl.agent_id
|
|
WHERE tm.team_id = $1
|
|
AND atl.template_id = $2",
|
|
)
|
|
.bind(team_id)
|
|
.bind(template_id)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
seeded_count, 3,
|
|
"expected every claw to have seeded lineage"
|
|
);
|
|
|
|
// Mission row was updated to point at the new team.
|
|
let bound_team_id: Uuid = sqlx::query_scalar("SELECT team_id FROM missions WHERE id = $1")
|
|
.bind(mission_id)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(bound_team_id, team_id);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn on_launch_is_idempotent() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let ws = seed_workspace(&pool).await;
|
|
let owner = seed_owner(&pool, ws).await;
|
|
let template_id = seed_test_template(&pool).await;
|
|
let mission_id = seed_mission(&pool, ws, template_id, "Idempotency Mission").await;
|
|
|
|
let team_a = mission_orchestrator::on_launch(&pool, ws, owner, mission_id, None, None)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let team_b = mission_orchestrator::on_launch(&pool, ws, owner, mission_id, None, None)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(
|
|
team_a, team_b,
|
|
"second invocation should return the same team_id"
|
|
);
|
|
|
|
// Still exactly three agents — no duplication.
|
|
let agent_count: i64 =
|
|
sqlx::query_scalar("SELECT count(*)::bigint FROM team_members WHERE team_id = $1")
|
|
.bind(team_a)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(agent_count, 3);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn on_launch_no_template_hard_fails() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let ws = seed_workspace(&pool).await;
|
|
let owner = seed_owner(&pool, ws).await;
|
|
|
|
// Mission with no team_template_id.
|
|
let mission_id = Uuid::now_v7();
|
|
sqlx::query(
|
|
"INSERT INTO missions
|
|
(id, workspace_id, title, template_kind, schedule, status, config)
|
|
VALUES ($1, $2, 'no template', 'refactor', '{\"kind\":\"one_shot\"}'::jsonb,
|
|
'draft', '{}'::jsonb)",
|
|
)
|
|
.bind(mission_id)
|
|
.bind(ws.as_uuid())
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
let result = mission_orchestrator::on_launch(&pool, ws, owner, mission_id, None, None).await;
|
|
let err = result.expect_err("no template + no team must be a hard error");
|
|
assert!(
|
|
err.contains("no team_template_id") && err.contains("config.phase_teams"),
|
|
"unexpected error message: {err}"
|
|
);
|
|
|
|
// 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")
|
|
.bind(mission_id)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert!(team_id.is_none());
|
|
}
|
|
|
|
/// Slice 5: an APPROVED roster outranks the team template.
|
|
///
|
|
/// The template gives every composed mission the same five roles on the same
|
|
/// image. A roster is the model's answer for THIS mission, and it is the only
|
|
/// path that carries a per-node backend — which is how a mission runs more than
|
|
/// one provider at all. If the template won, a heterogeneous roster would be
|
|
/// accepted, stored, and then silently ignored at launch.
|
|
#[tokio::test]
|
|
async fn an_approved_roster_outranks_the_template() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let ws = seed_workspace(&pool).await;
|
|
let template_id = seed_test_template(&pool).await;
|
|
let mission = seed_mission(&pool, ws, template_id, "roster beats template").await;
|
|
|
|
// With no roster, the shape comes from the template — the behaviour every
|
|
// composed mission had before this slice.
|
|
let from_template = mission_orchestrator::composed_graph(&pool, mission, &["mission"])
|
|
.await
|
|
.expect("template graph")
|
|
.expect("the template supplies a shape");
|
|
let template_nodes = from_template["nodes"].as_array().unwrap().len();
|
|
assert!(template_nodes >= 1);
|
|
assert!(
|
|
from_template["nodes"][0]["attrs"].get("backend").is_none(),
|
|
"a template cannot express a per-node backend — that is the gap the roster fills"
|
|
);
|
|
|
|
// Approve a roster the way the route does: the built graph under
|
|
// `config.roster`.
|
|
let roster = cm_api::mission_roster::Roster {
|
|
topology_kind: "pipeline".into(),
|
|
members: vec![
|
|
cm_api::mission_roster::RosterMember {
|
|
role: "implementer".into(),
|
|
backend: Some("claude".into()),
|
|
rationale: None,
|
|
},
|
|
cm_api::mission_roster::RosterMember {
|
|
role: "verifier".into(),
|
|
backend: Some("kimi".into()),
|
|
rationale: None,
|
|
},
|
|
],
|
|
};
|
|
let graph = roster.graph().expect("a runnable graph");
|
|
sqlx::query(
|
|
"UPDATE missions SET config = jsonb_set(config, '{roster}', $2::jsonb, true) WHERE id = $1",
|
|
)
|
|
.bind(mission)
|
|
.bind(&graph)
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
let chosen = mission_orchestrator::composed_graph(&pool, mission, &["mission"])
|
|
.await
|
|
.expect("roster graph")
|
|
.expect("the roster supplies a shape");
|
|
let nodes = chosen["nodes"].as_array().unwrap();
|
|
assert_eq!(nodes.len(), 2, "the roster's two nodes, not the template's");
|
|
assert_eq!(nodes[0]["role"], "implementer");
|
|
assert_eq!(nodes[0]["attrs"]["backend"], "claude");
|
|
assert_eq!(nodes[1]["attrs"]["backend"], "kimi");
|
|
}
|
|
|
|
/// Migration 0071: a template role may name its own model, and the claw minted
|
|
/// for it must actually run on that model.
|
|
///
|
|
/// Before this, `mint_team_from_template` bound EVERY role to one literal — so a
|
|
/// template whose whole point is an independent reviewer minted a reviewer
|
|
/// sharing a model with the coder it reviews. That is the correlated failure the
|
|
/// cross-provider judge exists to break, reintroduced one layer down.
|
|
#[tokio::test]
|
|
async fn a_template_role_may_run_on_its_own_model() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let ws = seed_workspace(&pool).await;
|
|
let user = seed_owner(&pool, ws).await;
|
|
let template_id = seed_test_template(&pool).await;
|
|
let mission = seed_mission(&pool, ws, template_id, "per-role models").await;
|
|
|
|
mission_orchestrator::on_launch(&pool, ws, user, mission, None, None)
|
|
.await
|
|
.expect("launch");
|
|
|
|
let rows: Vec<(String, Option<String>)> = sqlx::query_as(
|
|
"SELECT job_title, model_binding FROM agents
|
|
WHERE workspace_id = $1 AND deleted_at IS NULL
|
|
ORDER BY job_title",
|
|
)
|
|
.bind(ws.as_uuid())
|
|
.fetch_all(&pool)
|
|
.await
|
|
.unwrap();
|
|
let by_role: std::collections::HashMap<_, _> = rows.into_iter().collect();
|
|
|
|
assert_eq!(
|
|
by_role.get("reviewer").and_then(|m| m.clone()).as_deref(),
|
|
Some("glm-4.7"),
|
|
"the reviewer must run the model its role names: {by_role:?}"
|
|
);
|
|
// And a role that names none still gets the mint's default, so every
|
|
// template written before 0071 behaves exactly as it did.
|
|
for silent in ["planner", "coder"] {
|
|
assert_eq!(
|
|
by_role.get(silent).and_then(|m| m.clone()).as_deref(),
|
|
Some("claude-sonnet-5"),
|
|
"{silent} named no model and must take the default"
|
|
);
|
|
}
|
|
}
|