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:
co-authored by
Claude Opus 5
parent
895413509d
commit
fe2451fd60
@@ -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,
|
||||||
};
|
};
|
||||||
cm_db::repo::agents::insert(pool, &agent, &AccessPolicy::default())
|
let claw_id = match reused {
|
||||||
.await
|
Some(existing) => {
|
||||||
.map_err(|e| format!("insert agent {}: {e}", role.slot))?;
|
eprintln!(
|
||||||
let claw_id = agent.id.as_uuid();
|
"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.
|
// 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(),
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user