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
+56
View File
@@ -899,6 +899,31 @@ async fn reap_mission_resources(state: &AppState, mission_id: Uuid) {
// drift back into skipping the container teardown.
let provisioner = crate::runtime_provision::RuntimeProvisioner::from_env();
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(
&state.pool,
&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)]
mod artifact_tests {
/// A filename reaches `Content-Disposition` after an AGENT chose it.