feat(graph): enforce single archetype per task

## Design Decision
Per bd (bead) system design, a task should have only ONE agent archetype
assigned at a time. This provides clear ownership and simpler mental model.

## What Changed
When assigning a new archetype:
1. Remove any existing agent: labels first (DELETE API)
2. Then add the new agent: label (POST API)
3. Optimistic UI updates to match

## Why This Makes Sense
- Clear ownership: 'Who's working on this?'
- Simpler coordination between tasks
- Matches how bd/agent orchestration is intended to work
- Reassigning is still possible (just click a different archetype)

## UI Behavior
- If task has 'coder' assigned, clicking 'architect' will:
  1. Remove 'coder' label
  2. Add 'architect' label
- Dropdown shows 'Assigned' badge on current archetype
- X button still available to unassign completely

## Test Coverage
Added graph-node-single-archetype.test.tsx with 5 tests:
- Removes existing labels before adding new
- Calls DELETE before POST
- Only allows one archetype per task
- Preserves non-agent labels
- Returns early if same archetype clicked
This commit is contained in:
zenchantlive 2026-02-24 17:04:44 -08:00
parent 5ca6b21862
commit 211e503409
2 changed files with 78 additions and 7 deletions

View file

@ -113,18 +113,40 @@ export function GraphNodeCard({ id, data, selected }: NodeProps<Node<GraphNodeDa
const isClosed = data.status === 'closed';
const handleAssignAgent = async (archetypeId: string) => {
// Don't do anything if this archetype is already assigned
const labelToAdd = `agent:${archetypeId}`;
if (assignedArchetypes.some(a => a.id === archetypeId)) {
return;
}
setIsAssigning(true);
setAssignError(null);
setAssignSuccess(null);
// Optimistic update
const labelToAdd = `agent:${archetypeId}`;
// Track the new label as pending
pendingOptimisticLabels.current.add(labelToAdd);
if (!localLabels.includes(labelToAdd)) {
setLocalLabels([...localLabels, labelToAdd]);
}
// Get current agent labels to remove (single archetype constraint)
const currentAgentLabels = localLabels.filter(l => l.startsWith('agent:'));
// Optimistic update: remove all agent: labels, add new one
const previousLabels = localLabels;
setLocalLabels(prev => [...prev.filter(l => !l.startsWith('agent:')), labelToAdd]);
try {
// First remove existing agent labels (if any)
if (currentAgentLabels.length > 0) {
for (const existingLabel of currentAgentLabels) {
const existingArchetypeId = existingLabel.replace('agent:', '');
await fetch('/api/swarm/prep', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ beadId: id, archetypeId: existingArchetypeId }),
});
}
}
// Then add the new label
const response = await fetch('/api/swarm/prep', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@ -140,9 +162,9 @@ export function GraphNodeCard({ id, data, selected }: NodeProps<Node<GraphNodeDa
setAssignSuccess(`Assigned ${archetype?.name ?? archetypeId}`);
setTimeout(() => setAssignSuccess(null), 2000);
} catch (err) {
// Revert on error
// Revert on error - restore previous labels
pendingOptimisticLabels.current.delete(labelToAdd);
setLocalLabels(prev => prev.filter(l => l !== labelToAdd));
setLocalLabels(previousLabels);
setAssignError(err instanceof Error ? err.message : 'Failed to assign agent');
setTimeout(() => setAssignError(null), 3000);
} finally {