feat(workforce): missions hire the agents you already have, and name them by role

Every zeroclaw mission minted a fresh team of claws. They are created
`lifecycle = 'permanent'` and nothing reaps them until the MISSION is deleted,
so the roster grew by a whole team per mission while each member worked exactly
once — "My Workforce" was a list of strangers, and upskilling had nothing
durable to act on.

A mission now hires the claw that already does the job, matched on
`agent_template_link (template_id, role_slot)`, minting only what is missing.
Oldest first, so reuse concentrates on the same few claws and their brains
actually accumulate rather than spreading thinly across a growing pool.

A claw on a RUNNING mission is not offered. Two missions driving the same
ZeroClaw agent and the same `.brain` at once is a data race with a model on the
other end of it, and minting a second claw is much cheaper than reasoning about
that.

A reused claw is NOT re-seeded from the template's brain_seed — that would
overwrite what it learned with its starting point, which is precisely the
accumulation this exists for.

Names are the role now (`planner`), not
`"{mission} · {purpose} · {template} · {slot}"`. That produced
"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. Which
mission a claw is on is context a caller can join to; it is not its name.

And the half that makes reuse safe rather than destructive: deleting a mission
now purges only claws no OTHER mission still employs. Without it, tidying up one
mission deletes staff another one holds — presenting as the roster quietly
shrinking rather than as an error. A test asserts the guard exists inside the
reaper AND runs before the purge, because a check after it is decoration.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-10 07:18:12 -07:00
co-authored by Claude Opus 5
parent 895413509d
commit fe2451fd60
3 changed files with 153 additions and 8 deletions
+49 -8
View File
@@ -463,10 +463,32 @@ async fn mint_team_from_template(
template.roles.len(),
));
};
// Hire the claw that already does this job, if it is free.
//
// Every zeroclaw mission used to mint a fresh set. They are created
// `lifecycle = 'permanent'` and nothing reaps them until the MISSION is
// deleted, so the roster grew by a whole team per mission while each
// member worked once — and "My Workforce" was a list of strangers.
let reused = cm_db::repo::agent_template_link::reusable_claw(
pool,
workspace_id.as_uuid().to_owned(),
template.template.id,
&role.slot,
)
.await
.map_err(|e| format!("look up a reusable claw for {}: {e}", role.slot))?;
let agent = Agent {
id: cm_domain::AgentId::new(),
workspace_id,
name: format!("{} · {}", team_name, role.slot),
// The ROLE, not the mission.
//
// This was `"{mission title} · {purpose} · {template} · {slot}"`,
// which produced 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. Which mission a
// claw is on is context a caller can join to; it is not its name.
name: role.slot.clone(),
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
@@ -483,10 +505,23 @@ async fn mint_team_from_template(
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();
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))?;
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
@@ -498,7 +533,7 @@ async fn mint_team_from_template(
.map(str::trim)
.filter(|m| !m.is_empty())
.unwrap_or(default_model);
cm_db::repo::agents::set_model_binding(pool, agent.id, role_model)
cm_db::repo::agents::set_model_binding(pool, agent_id, role_model)
.await
.map_err(|e| format!("set_model_binding {claw_id}: {e}"))?;
@@ -518,7 +553,7 @@ async fn mint_team_from_template(
.provision_claw(claw_id, role_model, &template.template.risk_profile)
.await
{
Ok(_) => provisioned_claws.push(agent.id),
Ok(_) => provisioned_claws.push(agent_id),
Err(e) => eprintln!(
"mission_orchestrator: provision claw {claw_id} failed (continuing): {e}"
),
@@ -527,6 +562,10 @@ async fn mint_team_from_template(
// 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())
@@ -537,6 +576,7 @@ async fn mint_team_from_template(
);
}
}
}
// Record lineage (Slice 3.5d) so the MCP skills server can
// merge template default skills with per-agent overrides.
@@ -565,9 +605,10 @@ async fn mint_team_from_template(
cm_db::repo::audit::Actor::User(user_id),
"agent.created",
"agent",
&agent.id.to_string(),
&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(),