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
@@ -450,6 +450,17 @@ async fn mint_team_from_template(
|
||||
.await
|
||||
.map_err(|e| format!("stamp template lineage: {e}"))?;
|
||||
|
||||
// Names already on this workspace's roster, so a newly hired claw does not
|
||||
// arrive sharing a name with someone already here. Read ONCE — a roster
|
||||
// query per role would be N queries to answer one question — and extended
|
||||
// locally as we mint, which also keeps names distinct WITHIN this team.
|
||||
let mut taken_names: Vec<String> = cm_db::repo::agents::roster(pool, workspace_id)
|
||||
.await
|
||||
.map_err(|e| format!("read roster for naming: {e}"))?
|
||||
.into_iter()
|
||||
.map(|a| a.name)
|
||||
.collect();
|
||||
|
||||
// For each role: create agent, provision runtime, ingest brain
|
||||
// seed, record link, bind to topology node.
|
||||
for (idx, role) in template.roles.iter().enumerate() {
|
||||
@@ -481,14 +492,22 @@ async fn mint_team_from_template(
|
||||
let agent = Agent {
|
||||
id: cm_domain::AgentId::new(),
|
||||
workspace_id,
|
||||
// The ROLE, not the mission.
|
||||
// A PERSON's name, with the role in `job_title`.
|
||||
//
|
||||
// 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(),
|
||||
// This was `"{mission title} · {purpose} · {template} · {slot}"` —
|
||||
// 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. Then it was the bare slot, which
|
||||
// fixed the length but made the UI show the same word twice (name
|
||||
// on top, role beneath) and made a roster of five read as five job
|
||||
// tickets rather than a crew.
|
||||
//
|
||||
// The role still lives 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. Only the reused branch below ignores this, deliberately: a
|
||||
// claw you already hired keeps the name it already had.
|
||||
name: crate::agent_names::pick(&taken_names, idx as u64),
|
||||
job_title: role.slot.clone(),
|
||||
// This is the ONLY consumer of the templates' `system_prompt` prose,
|
||||
// and it feeds the *chat* path, not missions: it lands in
|
||||
@@ -518,6 +537,11 @@ async fn mint_team_from_template(
|
||||
cm_db::repo::agents::insert(pool, &agent, &AccessPolicy::default())
|
||||
.await
|
||||
.map_err(|e| format!("insert agent {}: {e}", role.slot))?;
|
||||
// Claim the name for the rest of this loop. Without this the
|
||||
// roster snapshot taken before the loop is stale from the
|
||||
// second role onward and a five-person team can arrive with
|
||||
// two Merediths.
|
||||
taken_names.push(agent.name.clone());
|
||||
agent.id.as_uuid()
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user