feat(workforce): every mission hires its own crew

Reverses the reuse added earlier, by operator decision. Reuse hired the
existing claw for a (template, slot) so the roster stayed at one team — but it
also meant every mission was staffed by the same five names, and the workforce
view showed one crew repeated down the page with nothing to tell the missions
apart. Distinct crews read better than a bounded roster.

The cost is the one reuse existed to avoid: claws are lifecycle='permanent'
and nothing reaps them until their MISSION is deleted, so the roster now grows
by the team size per mission. `agent_names::pick` keeps names unique
workspace-wide and degrades to a numeric suffix rather than colliding, and the
pool grew from 70 to 200+ given names so a workspace runs ~35 missions before
the first repeat. `reusable_claw` is kept in cm-db with its tests: this policy
has now flipped twice and the query is the hard part.

Also revives a test that had silently stopped running. An edit stranded
`runtime_data_is_scoped_to_one_mission`'s `#[test]` above its neighbour,
leaving two attributes there and none here — so the neighbour ran TWICE and
this one never ran at all. The total test count was unchanged by the fix
(291 before and after), which is exactly why a count is not evidence: rustc
had said "duplicated attribute" and "function is never used" all along, and
both read as ordinary warnings. The test guards per-mission `/zeroclaw-data`
isolation, i.e. one mission reading another's door token.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-10 15:53:12 -07:00
co-authored by Claude Opus 5
parent c85027c83a
commit 98037f9b3e
3 changed files with 116 additions and 32 deletions
+78 -15
View File
@@ -15,21 +15,51 @@
/// Given names, deliberately wide. Kept as one flat list rather than grouped by /// Given names, deliberately wide. Kept as one flat list rather than grouped by
/// origin: grouping invites picking "one from each", which is a worse kind of /// origin: grouping invites picking "one from each", which is a worse kind of
/// tokenism than simply having a broad pool and drawing from it evenly. /// tokenism than simply having a broad pool and drawing from it evenly.
///
/// Size is a product decision, not an aesthetic one. Every mission now mints
/// its own crew and nothing retires them, so the roster grows by the team size
/// per mission — at ~5 a mission a 70-name pool starts emitting "Amara 2"
/// inside twenty missions. This pool carries a few hundred so a workspace runs
/// for a long time before any name repeats at all.
pub const NAMES: &[&str] = &[ pub const NAMES: &[&str] = &[
// A–E // A
"Amara", "Anjali", "Arjun", "Astrid", "Ayo", "Bilal", "Camila", "Chidi", "Dagny", "Dilnoza", "Aarav", "Abebe", "Adaora", "Adrian", "Agnieszka", "Ahmad", "Aiko", "Ainhoa", "Alejandro",
"Ekaterina", "Elias", "Esi", "Eun-ji", "Alina", "Amara", "Amina", "Anders", "Andrea", "Anjali", "Annika", "Antoine", "Arjun", "Astrid",
// F–J "Ayo", "Ayesha", "Aziz",
"Farida", "Fatou", "Freya", "Gabriel", "Giulia", "Hasan", "Hina", "Ibrahim", "Ingrid", "Isabela", // B–C
"Jaromir", "Jing", "Josefina", "Junko", "Beatriz", "Bilal", "Bjorn", "Blessing", "Bogdan", "Camila", "Carlos", "Catalina", "Chidi",
// K–O "Chiara", "Chioma", "Cyrus",
"Kaito", "Kalinda", "Kwame", "Lars", "Leilani", "Lucia", "Mateo", "Meredith", "Mira", "Nadia", // D–E
"Neelam", "Niamh", "Nkechi", "Oleksii", "Omar", "Oskar", "Dagny", "Damir", "Daniela", "Dilnoza", "Dmitri", "Ebele", "Eduardo", "Eero", "Ekaterina",
// P–T "Elena", "Elias", "Emeka", "Enrique", "Esi", "Esther", "Eun-ji", "Ewa",
"Paloma", "Priya", "Rafael", "Ravi", "Renata", "Robert", "Rosalind", "Sadia", "Salome", "Sanjay", // F–G
"Sipho", "Soren", "Tariq", "Thandiwe", "Tim", "Tomasz", "Tuva", "Fabio", "Farida", "Fatou", "Felipe", "Fernanda", "Freya", "Gabriel", "Georgi", "Giulia",
// U–Z "Grace", "Gunnar", "Gulnara",
"Uma", "Valentina", "Vijay", "Wanjiru", "Yara", "Yusuf", "Zainab", "Zoltan", // H–I
"Hana", "Hasan", "Heidi", "Hina", "Hiroshi", "Ibrahim", "Idris", "Ilya", "Imani", "Ingrid",
"Iris", "Isabela", "Ivan", "Iwona",
// J–K
"Jaromir", "Javier", "Jing", "Joana", "Johan", "Josefina", "Junko", "Kaito", "Kalinda", "Karim",
"Katarzyna", "Kenji", "Khalid", "Kiran", "Klara", "Kwame", "Kyoko",
// L–M
"Lakshmi", "Lars", "Laila", "Leilani", "Lena", "Liam", "Linnea", "Lucia", "Lukas", "Madhavi",
"Maja", "Malik", "Marisol", "Mateo", "Matteo", "Mei", "Meredith", "Milena", "Mira", "Mohan",
"Mira-Lynn", "Mateusz",
// N–O
"Nadia", "Nasrin", "Neelam", "Niamh", "Nikolai", "Nilufar", "Nkechi", "Noor", "Nuria", "Oksana",
"Oleksii", "Olamide", "Omar", "Oskar", "Osei",
// P–R
"Paloma", "Panagiotis", "Pedro", "Petra", "Priya", "Rafael", "Rania", "Ravi", "Reza", "Renata",
"Rin", "Robert", "Rosalind", "Rustam",
// S
"Sadia", "Salome", "Samir", "Sanjay", "Sara", "Seong-min", "Sipho", "Sofia", "Solveig", "Soren",
"Suvi", "Svetlana",
// T–U
"Tadeusz", "Takeshi", "Tamar", "Tariq", "Thandiwe", "Thi", "Tim", "Tomasz", "Tove", "Tuva",
"Ulrika", "Uma", "Usman",
// V–Z
"Valentina", "Vera", "Vijay", "Vikram", "Wanjiru", "Wei", "Wiktor", "Yara", "Yasmin", "Yohannes",
"Yuki", "Yusuf", "Zainab", "Zara", "Zoltan", "Zuzanna",
]; ];
/// Pick a name not already in `taken`. /// Pick a name not already in `taken`.
@@ -75,7 +105,40 @@ mod tests {
assert!(!n.trim().is_empty(), "empty name in the pool"); assert!(!n.trim().is_empty(), "empty name in the pool");
assert!(seen.insert(n.to_ascii_lowercase()), "duplicate in pool: {n}"); assert!(seen.insert(n.to_ascii_lowercase()), "duplicate in pool: {n}");
} }
assert!(NAMES.len() >= 50, "pool too small to keep a roster varied"); // Every mission mints its own crew and nothing retires them, so the
// pool is consumed for the life of the workspace, not recycled. At ~5
// per mission this is ~35 missions before the first numeric suffix.
assert!(NAMES.len() >= 150, "pool too small for one crew per mission");
}
/// The scenario the operator actually asked for: consecutive missions must
/// not hand back the same names. Reuse is off, so mission two staffs from
/// what mission one left.
#[test]
fn consecutive_missions_get_different_crews() {
let mut roster: Vec<String> = Vec::new();
let mut crews: Vec<Vec<String>> = Vec::new();
for mission in 0..6u64 {
let mut crew = Vec::new();
for role in 0..5u64 {
let n = pick(&roster, mission * 5 + role);
roster.push(n.clone());
crew.push(n);
}
crews.push(crew);
}
for (i, a) in crews.iter().enumerate() {
for (j, b) in crews.iter().enumerate().skip(i + 1) {
let shared: Vec<_> = a.iter().filter(|n| b.contains(n)).collect();
assert!(
shared.is_empty(),
"missions {i} and {j} share {shared:?} — crews must be distinct"
);
}
}
// And no duplicates anywhere on the roster.
let uniq: std::collections::HashSet<_> = roster.iter().collect();
assert_eq!(uniq.len(), roster.len(), "a name was issued twice");
} }
#[test] #[test]
+19 -13
View File
@@ -474,20 +474,26 @@ 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 mission gets its OWN crew.
// //
// Every zeroclaw mission used to mint a fresh set. They are created // This deliberately reverses the reuse added earlier. Reuse hired the
// `lifecycle = 'permanent'` and nothing reaps them until the MISSION is // existing claw for a (template, slot) so the roster stayed at one team
// deleted, so the roster grew by a whole team per mission while each // and "My Workforce" was people you keep — but it also meant every
// member worked once — and "My Workforce" was a list of strangers. // mission was staffed by the same five names, and the workforce view
let reused = cm_db::repo::agent_template_link::reusable_claw( // showed one crew repeated down the page with nothing to tell the
pool, // missions apart. Chosen by the operator: distinct crews read better
workspace_id.as_uuid().to_owned(), // than a bounded roster.
template.template.id, //
&role.slot, // The cost is real and is the cost that reuse existed to avoid: claws
) // are `lifecycle = 'permanent'` and nothing reaps them until their
.await // MISSION is deleted, so the roster now grows by the team size on every
.map_err(|e| format!("look up a reusable claw for {}: {e}", role.slot))?; // mission. `agent_names::pick` keeps names unique workspace-wide and
// falls back to a numeric suffix once the pool is exhausted, so growth
// degrades the naming gracefully rather than colliding.
//
// `reusable_claw` in cm-db is kept, with its tests: this is a policy
// choice that has now flipped twice, and the query is the hard part.
let reused: Option<uuid::Uuid> = None;
let agent = Agent { let agent = Agent {
id: cm_domain::AgentId::new(), id: cm_domain::AgentId::new(),
+19 -4
View File
@@ -1631,10 +1631,6 @@ allowed_tools = ["file_read", "file_edit"]
/// removes `/var/lib/clawmates-missions/{id}`, so the shared dir was /// removes `/var/lib/clawmates-missions/{id}`, so the shared dir was
/// never cleaned. /// never cleaned.
/// ///
/// Asserting the path shape is what keeps this from silently regressing:
/// a future edit that points the mount back at the seed dir restores the
/// credential sharing with no other visible symptom.
#[test]
/// The seed copy reads as root and leaves the result to 65532. /// The seed copy reads as root and leaves the result to 65532.
/// ///
/// Both halves are load-bearing and they pull in opposite directions. The /// Both halves are load-bearing and they pull in opposite directions. The
@@ -1664,6 +1660,25 @@ allowed_tools = ["file_read", "file_edit"]
assert!(chown > last_cp, "chown must run after the copies"); assert!(chown > last_cp, "chown must run after the copies");
} }
/// The seed data path must be per-mission, not the shared seed dir.
///
/// Every mission container used to bind the SAME host directory as
/// `/zeroclaw-data`. It holds `config.toml`, which carries the §15 door
/// bearer token, plus `sessions.db`/`devices.db`. Sharing it meant one
/// mission could read another's credential, and anything written there
/// was inherited by every later mission — `teardown_container` only
/// removes `/var/lib/clawmates-missions/{id}`, so the shared dir was
/// never cleaned.
///
/// Asserting the path shape is what keeps this from silently regressing:
/// a future edit that points the mount back at the seed dir restores the
/// credential sharing with no other visible symptom.
///
/// This test did not RUN for some time: an edit stranded its `#[test]`
/// above the neighbouring function, leaving two attributes there and none
/// here. rustc said so twice — "duplicated attribute" and "function is
/// never used" — and both read as ordinary warnings in a noisy build.
#[test]
fn runtime_data_is_scoped_to_one_mission() { fn runtime_data_is_scoped_to_one_mission() {
let a = Uuid::now_v7(); let a = Uuid::now_v7();
let b = Uuid::now_v7(); let b = Uuid::now_v7();