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
@@ -1683,6 +1683,118 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/workforce — the roster grouped by the mission each claw works on.
|
||||
///
|
||||
/// The sidebar used to flatten `orgs → companies → teams → agents`, which
|
||||
/// rendered a claw once per TEAM it belongs to. Since claws are reused across
|
||||
/// missions, a crew of five that had run five missions appeared as twenty-five
|
||||
/// rows of the same five people — the roster looked like it was multiplying.
|
||||
///
|
||||
/// Grouping by mission makes that repetition mean something: the same person
|
||||
/// legitimately appears under each mission they staffed. `agents` is deduped
|
||||
/// per mission, and claws belonging to no mission come back under `unassigned`
|
||||
/// so a hand-created claw cannot fall out of the UI entirely.
|
||||
pub async fn workforce(
|
||||
State(state): State<AppState>,
|
||||
Authed(user): Authed,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
use sqlx::Row;
|
||||
let ws = user.workspace_id.as_uuid();
|
||||
|
||||
// One query, not one-per-mission: the sidebar renders on every navigation.
|
||||
let rows = sqlx::query(
|
||||
"SELECT m.id::text AS mission_id,
|
||||
m.title AS mission_title,
|
||||
m.status AS mission_status,
|
||||
m.created_at AS created_at,
|
||||
a.id::text AS agent_id,
|
||||
a.name AS agent_name,
|
||||
a.job_title AS job_title,
|
||||
a.accent AS accent,
|
||||
a.status AS agent_status,
|
||||
tm.role_slot AS role_slot
|
||||
FROM missions m
|
||||
JOIN mission_teams mt ON mt.mission_id = m.id
|
||||
JOIN team_members tm ON tm.team_id = mt.team_id
|
||||
JOIN agents a ON a.id = tm.claw_id
|
||||
WHERE m.workspace_id = $1
|
||||
AND a.deleted_at IS NULL
|
||||
ORDER BY m.created_at DESC, tm.role_slot ASC",
|
||||
)
|
||||
.bind(ws)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let mut missions: Vec<Value> = Vec::new();
|
||||
let mut seen_mission: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
||||
for r in rows {
|
||||
let mid: String = r.get("mission_id");
|
||||
let idx = match seen_mission.get(&mid) {
|
||||
Some(i) => *i,
|
||||
None => {
|
||||
missions.push(serde_json::json!({
|
||||
"mission_id": mid,
|
||||
"title": r.get::<String, _>("mission_title"),
|
||||
"status": r.get::<String, _>("mission_status"),
|
||||
"agents": Vec::<Value>::new(),
|
||||
}));
|
||||
seen_mission.insert(r.get::<String, _>("mission_id"), missions.len() - 1);
|
||||
missions.len() - 1
|
||||
}
|
||||
};
|
||||
let agent = serde_json::json!({
|
||||
"id": r.get::<String, _>("agent_id"),
|
||||
"name": r.get::<String, _>("agent_name"),
|
||||
"job_title": r.get::<String, _>("job_title"),
|
||||
"role_slot": r.get::<String, _>("role_slot"),
|
||||
"accent": r.get::<String, _>("accent"),
|
||||
"status": r.get::<String, _>("agent_status"),
|
||||
});
|
||||
// A claw bound to two NODES of the same mission is still one colleague.
|
||||
let list = missions[idx]["agents"].as_array_mut().expect("agents array");
|
||||
let id = agent["id"].clone();
|
||||
if !list.iter().any(|a| a["id"] == id) {
|
||||
list.push(agent);
|
||||
}
|
||||
}
|
||||
|
||||
// Claws on no mission at all — hand-created, or whose missions were
|
||||
// deleted. Without this they would simply vanish from the sidebar.
|
||||
let loose = sqlx::query(
|
||||
"SELECT a.id::text AS agent_id, a.name, a.job_title, a.accent, a.status
|
||||
FROM agents a
|
||||
WHERE a.workspace_id = $1
|
||||
AND a.deleted_at IS NULL
|
||||
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.workspace_id = $1)
|
||||
ORDER BY a.name ASC",
|
||||
)
|
||||
.bind(ws)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
let unassigned: Vec<Value> = loose
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
serde_json::json!({
|
||||
"id": r.get::<String, _>("agent_id"),
|
||||
"name": r.get::<String, _>("name"),
|
||||
"job_title": r.get::<String, _>("job_title"),
|
||||
"role_slot": Value::Null,
|
||||
"accent": r.get::<String, _>("accent"),
|
||||
"status": r.get::<String, _>("status"),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"missions": missions,
|
||||
"unassigned": unassigned,
|
||||
})))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod reap_tests {
|
||||
/// A mission's teardown must ask whether anyone else still employs a claw.
|
||||
|
||||
Reference in New Issue
Block a user