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
+46 -5
View File
@@ -463,10 +463,32 @@ async fn mint_team_from_template(
template.roles.len(), 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 { let agent = Agent {
id: cm_domain::AgentId::new(), id: cm_domain::AgentId::new(),
workspace_id, 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(), job_title: role.slot.clone(),
// This is the ONLY consumer of the templates' `system_prompt` prose, // This is the ONLY consumer of the templates' `system_prompt` prose,
// and it feeds the *chat* path, not missions: it lands in // 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, managed_by: user_id,
status: AgentStatus::Online, status: AgentStatus::Online,
}; };
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()) cm_db::repo::agents::insert(pool, &agent, &AccessPolicy::default())
.await .await
.map_err(|e| format!("insert agent {}: {e}", role.slot))?; .map_err(|e| format!("insert agent {}: {e}", role.slot))?;
let claw_id = agent.id.as_uuid(); 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. // 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 // 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) .map(str::trim)
.filter(|m| !m.is_empty()) .filter(|m| !m.is_empty())
.unwrap_or(default_model); .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 .await
.map_err(|e| format!("set_model_binding {claw_id}: {e}"))?; .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) .provision_claw(claw_id, role_model, &template.template.risk_profile)
.await .await
{ {
Ok(_) => provisioned_claws.push(agent.id), Ok(_) => provisioned_claws.push(agent_id),
Err(e) => eprintln!( Err(e) => eprintln!(
"mission_orchestrator: provision claw {claw_id} failed (continuing): {e}" "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 — // Ingest brain seed (Slice 3.5d). Non-fatal on failure —
// agent still works from system_prompt alone. // 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 Some(seed) = role.brain_seed.as_deref().filter(|s| !s.trim().is_empty()) {
if let Err(e) = if let Err(e) =
crate::brain_seed::ingest(claw_id, seed.to_string(), role.system_prompt.clone()) 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 // Record lineage (Slice 3.5d) so the MCP skills server can
// merge template default skills with per-agent overrides. // 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), cm_db::repo::audit::Actor::User(user_id),
"agent.created", "agent.created",
"agent", "agent",
&agent.id.to_string(), &agent_id.to_string(),
serde_json::json!({ serde_json::json!({
"name": agent.name, "name": agent.name,
"reused": reused.is_some(),
"job_title": agent.job_title, "job_title": agent.job_title,
"source": "mission_orchestrator", "source": "mission_orchestrator",
"template_id": template.template.id.to_string(), "template_id": template.template.id.to_string(),
+56
View File
@@ -899,6 +899,31 @@ async fn reap_mission_resources(state: &AppState, mission_id: Uuid) {
// drift back into skipping the container teardown. // drift back into skipping the container teardown.
let provisioner = crate::runtime_provision::RuntimeProvisioner::from_env(); let provisioner = crate::runtime_provision::RuntimeProvisioner::from_env();
for cid in &claw_ids { for cid in &claw_ids {
// Only claws this mission is the LAST holder of.
//
// Claws are reused across missions now (see
// `agent_template_link::reusable_claw`), so a mission's team can contain
// staff that other missions still employ. Purging those would delete a
// user's workforce as a side effect of tidying up one mission — and it
// would look like the roster quietly shrinking, not like an error.
let shared: i64 = sqlx::query_scalar(
"SELECT count(*)
FROM team_members tm
JOIN mission_teams mt ON mt.team_id = tm.team_id
WHERE tm.claw_id = $1 AND mt.mission_id <> $2",
)
.bind(cid)
.bind(mission_id)
.fetch_one(&state.pool)
.await
.unwrap_or(0);
if shared > 0 {
eprintln!(
"missions::delete: keeping claw {cid} — {shared} other mission(s) still employ it"
);
continue;
}
let report = crate::routes::claws::purge_agent( let report = crate::routes::claws::purge_agent(
&state.pool, &state.pool,
&state.runtime, &state.runtime,
@@ -1658,6 +1683,37 @@ mod tests {
} }
} }
#[cfg(test)]
mod reap_tests {
/// A mission's teardown must ask whether anyone else still employs a claw.
///
/// Claws are reused across missions now, so a mission's team can contain
/// staff other missions still hold. The old code purged every claw in the
/// team unconditionally, which under reuse deletes a user's workforce as a
/// side effect of tidying one mission — and it presents as the roster
/// quietly shrinking rather than as an error.
#[test]
fn mission_teardown_checks_for_other_employers_before_purging() {
let src = include_str!("missions.rs");
let reaper = src
.split("async fn reap_mission_resources")
.nth(1)
.expect("the reaper exists");
// Scoped to the reaper, so the check cannot be satisfied by some other
// function elsewhere in the file that happens to mention mission_teams.
assert!(
reaper.contains("mt.mission_id <> $2"),
"the purge must exclude claws held by another mission"
);
let purge_at = reaper.find("purge_agent").expect("it still purges");
let guard_at = reaper.find("mt.mission_id <> $2").expect("guard present");
assert!(
guard_at < purge_at,
"the guard has to run BEFORE the purge, or it is decoration"
);
}
}
#[cfg(test)] #[cfg(test)]
mod artifact_tests { mod artifact_tests {
/// A filename reaches `Content-Disposition` after an AGENT chose it. /// A filename reaches `Content-Disposition` after an AGENT chose it.
@@ -113,3 +113,51 @@ pub async fn agents_for_template(
}) })
.collect()) .collect())
} }
/// A claw already in this workspace that can take this template role again.
///
/// The workforce is meant to be KEPT: a mission that needs a `coder` should
/// hire the one that already exists rather than mint a sixth. Without this,
/// every zeroclaw mission added a whole team to the roster permanently — they
/// are minted `lifecycle = 'permanent'` and nothing reaps them until the
/// mission itself is deleted — while each member was used exactly once.
///
/// A claw currently 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; minting a second claw is much cheaper than
/// reasoning about that.
///
/// Oldest first, so reuse concentrates on the same few claws and their brains
/// actually accumulate, instead of spreading thinly across a growing pool.
pub async fn reusable_claw(
pool: &PgPool,
workspace_id: uuid::Uuid,
template_id: uuid::Uuid,
role_slot: &str,
) -> Result<Option<uuid::Uuid>, DbError> {
let row: Option<(uuid::Uuid,)> = sqlx::query_as(
"SELECT a.id
FROM agents a
JOIN agent_template_link l ON l.agent_id = a.id
WHERE a.workspace_id = $1
AND a.deleted_at IS NULL
AND l.template_id = $2
AND l.role_slot = $3
AND NOT EXISTS (
SELECT 1
FROM team_members tm
JOIN mission_teams mt ON mt.team_id = tm.team_id
JOIN missions m ON m.id = mt.mission_id
WHERE tm.claw_id = a.id
AND m.status = 'running'
)
ORDER BY a.created_at
LIMIT 1",
)
.bind(workspace_id)
.bind(template_id)
.bind(role_slot)
.fetch_optional(pool)
.await?;
Ok(row.map(|(id,)| id))
}