feat(workforce): missions group the roster, and agents get human names
Three things, all visible on the agents page.
**The roster looked like it was multiplying.** The sidebar flattened
orgs → companies → teams → agents, which renders a claw once per TEAM it
belongs to. Claws are reused across missions now, so a crew of five that had
run five missions appeared as twenty-five rows of the same five people. The
data was right and the view was lying. `GET /api/workforce` returns the roster
grouped by mission, and the tree renders each mission as a collapsible group,
so the repetition means something: the same colleague under each mission they
staffed. Claws on no mission come back under "Not on a mission" rather than
vanishing. The root now counts DISTINCT people, not rows.
**Agents were named after their jobs.** A team came back as planner, coder,
tester, reviewer, committer — the UI showed the same word twice (name on top,
role beneath) and the roster read as a stack of job tickets. New claws get a
given name from a deliberately wide pool (Amara, Vijay, Tomasz, Meredith…),
unique against the workspace roster AND within the team being minted. The role
is untouched in `job_title`, which is what the mission machinery binds on:
team_members.role_slot and the topology node carry the slot, so nothing
downstream keys off the display name. A reused claw keeps the name it had.
**Two latent reap bugs found while investigating a leak that was not one.**
Containers of completed missions are removed by `spawn_sweeper` after a
30-minute grace, and it works — an earlier report of leaking containers was me
reading that deliberate grace as a bug. But:
- the sweeper cleared the runtime binding even when teardown FAILED, and it
selects on `runtime_endpoint IS NOT NULL`. One transient docker error would
therefore hide a surviving container from the only thing that would retry
it, permanently. It now asks docker whether the container actually
survived: gone means clear, still there means keep the binding and retry —
which closes the orphan path without reintroducing the infinite retry the
original comment was guarding against.
- `set_runtime_binding` discarded rows_affected, so a mismatched workspace
updated nothing and returned Ok. The binding is how the sweeper finds a
container; a silent no-op there leaks one with no record of anything wrong.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e2c312b728
commit
0ad53da49c
@@ -0,0 +1,131 @@
|
||||
//! Human given names for minted agents.
|
||||
//!
|
||||
//! A team used to come back as `planner`, `coder`, `tester`, `reviewer`,
|
||||
//! `committer` — the roster read as a list of job tickets, and the UI showed
|
||||
//! the same word twice (name on top, role underneath). A crew you keep should
|
||||
//! read like people: Meredith, Vijay, Tomasz, Amara.
|
||||
//!
|
||||
//! The role is not lost — it stays in `job_title`, which is what the mission
|
||||
//! machinery binds on. Only the display identity changes.
|
||||
//!
|
||||
//! Names are drawn from many naming traditions on purpose: this workforce is
|
||||
//! not from one place. They are given names only — no surnames — so nobody
|
||||
//! reads a claw as a specific real person.
|
||||
|
||||
/// 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
|
||||
/// tokenism than simply having a broad pool and drawing from it evenly.
|
||||
pub const NAMES: &[&str] = &[
|
||||
// A–E
|
||||
"Amara", "Anjali", "Arjun", "Astrid", "Ayo", "Bilal", "Camila", "Chidi", "Dagny", "Dilnoza",
|
||||
"Ekaterina", "Elias", "Esi", "Eun-ji",
|
||||
// F–J
|
||||
"Farida", "Fatou", "Freya", "Gabriel", "Giulia", "Hasan", "Hina", "Ibrahim", "Ingrid", "Isabela",
|
||||
"Jaromir", "Jing", "Josefina", "Junko",
|
||||
// K–O
|
||||
"Kaito", "Kalinda", "Kwame", "Lars", "Leilani", "Lucia", "Mateo", "Meredith", "Mira", "Nadia",
|
||||
"Neelam", "Niamh", "Nkechi", "Oleksii", "Omar", "Oskar",
|
||||
// P–T
|
||||
"Paloma", "Priya", "Rafael", "Ravi", "Renata", "Robert", "Rosalind", "Sadia", "Salome", "Sanjay",
|
||||
"Sipho", "Soren", "Tariq", "Thandiwe", "Tim", "Tomasz", "Tuva",
|
||||
// U–Z
|
||||
"Uma", "Valentina", "Vijay", "Wanjiru", "Yara", "Yusuf", "Zainab", "Zoltan",
|
||||
];
|
||||
|
||||
/// Pick a name not already in `taken`.
|
||||
///
|
||||
/// `seed` spreads the starting point so a workspace does not always begin at
|
||||
/// "Amara" — it is an offset into the list, not randomness, so the choice is
|
||||
/// reproducible for a given (seed, taken) pair and therefore testable.
|
||||
///
|
||||
/// When every name is taken it appends a numeric suffix — `Amara 2` — rather
|
||||
/// than returning `None` and forcing the caller to invent something. Running
|
||||
/// out is a nice problem (70+ concurrent agents in one workspace) and a
|
||||
/// duplicate display name is far less harmful than a failed mission launch.
|
||||
pub fn pick(taken: &[String], seed: u64) -> String {
|
||||
let start = (seed % NAMES.len() as u64) as usize;
|
||||
for i in 0..NAMES.len() {
|
||||
let candidate = NAMES[(start + i) % NAMES.len()];
|
||||
if !taken.iter().any(|t| t.eq_ignore_ascii_case(candidate)) {
|
||||
return candidate.to_string();
|
||||
}
|
||||
}
|
||||
// Second pass with a suffix. `round` starts at 2 so the first repeat reads
|
||||
// "Amara 2", which is how a person would disambiguate two colleagues.
|
||||
for round in 2..1000 {
|
||||
for i in 0..NAMES.len() {
|
||||
let candidate = format!("{} {}", NAMES[(start + i) % NAMES.len()], round);
|
||||
if !taken.iter().any(|t| t.eq_ignore_ascii_case(&candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Unreachable in practice; still not a panic.
|
||||
format!("Agent {seed}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn names_are_unique_and_non_empty() {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for n in NAMES {
|
||||
assert!(!n.trim().is_empty(), "empty name in the pool");
|
||||
assert!(seen.insert(n.to_ascii_lowercase()), "duplicate in pool: {n}");
|
||||
}
|
||||
assert!(NAMES.len() >= 50, "pool too small to keep a roster varied");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_avoids_taken_names() {
|
||||
let taken: Vec<String> = NAMES.iter().take(10).map(|s| s.to_string()).collect();
|
||||
let got = pick(&taken, 0);
|
||||
assert!(
|
||||
!taken.iter().any(|t| t.eq_ignore_ascii_case(&got)),
|
||||
"picked a name already taken: {got}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_is_case_insensitive_about_taken() {
|
||||
// A name already on the roster in a different case is still taken —
|
||||
// "meredith" and "Meredith" are the same colleague.
|
||||
let taken = vec![NAMES[0].to_ascii_lowercase()];
|
||||
assert_ne!(pick(&taken, 0).to_ascii_lowercase(), taken[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_spreads_the_starting_point() {
|
||||
// Different seeds should not all hand back the same first name, or a
|
||||
// fresh workspace always opens with the same roster.
|
||||
let a = pick(&[], 0);
|
||||
let b = pick(&[], 7);
|
||||
assert_ne!(a, b, "seed had no effect on the choice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exhausting_the_pool_suffixes_rather_than_failing() {
|
||||
let taken: Vec<String> = NAMES.iter().map(|s| s.to_string()).collect();
|
||||
let got = pick(&taken, 0);
|
||||
assert!(
|
||||
!taken.iter().any(|t| t.eq_ignore_ascii_case(&got)),
|
||||
"must not reuse a taken name"
|
||||
);
|
||||
assert!(got.ends_with(" 2"), "expected a suffixed name, got {got}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_team_gets_distinct_names() {
|
||||
// The actual scenario: mint five roles into an empty workspace and get
|
||||
// five different people, not five "planner"s.
|
||||
let mut taken: Vec<String> = Vec::new();
|
||||
for i in 0..5 {
|
||||
let n = pick(&taken, i);
|
||||
assert!(!taken.contains(&n), "repeated {n} within one team");
|
||||
taken.push(n);
|
||||
}
|
||||
assert_eq!(taken.len(), 5);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user