feat(swarm-p2): agent identity, SwarmRegistry, and color dots for concurrent workers

- packages/types/src/swarm.ts: AgentColorName, AgentIdentity, SwarmSession,
  TeammateMessage stubs (P3)
- packages/types/src/history.ts: add swarmSessionId, agentId, agentName,
  agentColor, isSwarmLeader fields to HistoryItem schema
- packages/types/src/events.ts: add SwarmSessionStarted, SwarmSessionEnded,
  WorkerRegistered events to RooCodeEventName, rooCodeEventsSchema, taskEventSchema
- packages/types/src/task.ts: add swarm events to TaskProviderEvents so ClineProvider
  emit calls are properly typed
- src/core/swarm/SwarmRegistry.ts: round-robin color assignment, session lifecycle
- ClineProvider: add SwarmRegistry field; spawnConcurrentChildren now creates a
  session, assigns agentName/agentColor/agentId to each worker's HistoryItem,
  marks parent as isSwarmLeader, emits SwarmSessionStarted/WorkerRegistered/
  SwarmSessionEnded; accepts optional role field in task spec
- RunTeamPhaseTool: pass role through to spawnConcurrentChildren
- SubtaskRow: render 8px color dot (with tooltip showing agentName) when
  agentColor is set on a history item

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
davincidreams 2026-04-25 06:57:39 -04:00
parent b8e2e83a3f
commit 10b6690672
9 changed files with 229 additions and 14 deletions

View file

@ -51,6 +51,11 @@ export enum RooCodeEventName {
ModesResponse = "modesResponse",
ModelsResponse = "modelsResponse",
// Swarm Lifecycle
SwarmSessionStarted = "swarmSessionStarted",
SwarmSessionEnded = "swarmSessionEnded",
WorkerRegistered = "workerRegistered",
// Evals
EvalPass = "evalPass",
EvalFail = "evalFail",
@ -112,6 +117,10 @@ export const rooCodeEventsSchema = z.object({
[RooCodeEventName.TaskToolFailed]: z.tuple([z.string(), toolNamesSchema, z.string()]),
[RooCodeEventName.TaskTokenUsageUpdated]: z.tuple([z.string(), tokenUsageSchema, toolUsageSchema]),
[RooCodeEventName.SwarmSessionStarted]: z.tuple([z.string(), z.string()]), // [sessionId, leaderTaskId]
[RooCodeEventName.SwarmSessionEnded]: z.tuple([z.string(), z.string()]), // [sessionId, leaderTaskId]
[RooCodeEventName.WorkerRegistered]: z.tuple([z.string(), z.string(), z.string(), z.string()]), // [sessionId, taskId, agentName, agentColor]
[RooCodeEventName.ModeChanged]: z.tuple([z.string()]),
[RooCodeEventName.ProviderProfileChanged]: z.tuple([z.object({ name: z.string(), provider: z.string() })]),
@ -274,6 +283,23 @@ export const taskEventSchema = z.discriminatedUnion("eventName", [
taskId: z.number().optional(),
}),
// Swarm Lifecycle
z.object({
eventName: z.literal(RooCodeEventName.SwarmSessionStarted),
payload: rooCodeEventsSchema.shape[RooCodeEventName.SwarmSessionStarted],
taskId: z.number().optional(),
}),
z.object({
eventName: z.literal(RooCodeEventName.SwarmSessionEnded),
payload: rooCodeEventsSchema.shape[RooCodeEventName.SwarmSessionEnded],
taskId: z.number().optional(),
}),
z.object({
eventName: z.literal(RooCodeEventName.WorkerRegistered),
payload: rooCodeEventsSchema.shape[RooCodeEventName.WorkerRegistered],
taskId: z.number().optional(),
}),
// Evals
z.object({
eventName: z.literal(RooCodeEventName.EvalPass),

View file

@ -50,6 +50,12 @@ export const historyItemSchema = z.object({
)
.optional(), // Accumulated results from completed parallel children
abortOnChildFailure: z.boolean().optional(), // When true, the entire parallel queue is abandoned if any child fails
// Swarm identity — set by spawnConcurrentChildren for workers, and on the leader task
swarmSessionId: z.string().optional(),
agentId: z.string().optional(), // "<name>@<sessionId>"
agentName: z.string().optional(), // Human-readable role label
agentColor: z.string().optional(), // AgentColorName
isSwarmLeader: z.boolean().optional(),
})
export type HistoryItem = z.infer<typeof historyItemSchema>

View file

@ -23,6 +23,7 @@ export * from "./provider-settings.js"
export * from "./task.js"
export * from "./todo.js"
export * from "./skills.js"
export * from "./swarm.js"
export * from "./team.js"
export * from "./telemetry.js"
export * from "./terminal.js"

View file

@ -0,0 +1,55 @@
/**
* Swarm / multi-agent identity types.
* These types describe agent identities, sessions, and the message envelope
* used between agents (mailbox protocol, implemented in Phase 3+).
*/
export type AgentColorName = "red" | "blue" | "green" | "yellow" | "purple" | "orange" | "pink" | "cyan"
/**
* Stable identity for a single agent within a swarm session.
*/
export interface AgentIdentity {
/** Stable unique identifier — "<name>@<sessionId>" */
agentId: string
/** Human-readable role name (e.g. "Backend Engineer") */
agentName: string
/** Assigned display color */
color: AgentColorName
/** True for the task that called spawnConcurrentChildren */
isLeader: boolean
/** The Roo-Code taskId this identity is bound to */
taskId: string
}
/**
* In-memory session tracking all agents spawned by a single concurrent spawn call.
* Keyed by taskId in the `teammates` record.
*/
export interface SwarmSession {
/** Same as the leaderTaskId for simplicity */
sessionId: string
/** The parent task that created this session */
leaderTaskId: string
/** All registered worker identities, keyed by taskId */
teammates: Record<string, AgentIdentity>
}
// ---------------------------------------------------------------------------
// Mailbox protocol (P3 stubs — types only, no implementation yet)
// ---------------------------------------------------------------------------
export type TeammateMessageType =
| "task_assignment"
| "idle_notification"
| "shutdown_request"
| "permission_request"
| "permission_response"
export interface TeammateMessage {
type: TeammateMessageType
from: string
to: string
payload?: Record<string, unknown>
ts: number
}

View file

@ -85,6 +85,10 @@ export type TaskProviderEvents = {
[RooCodeEventName.ModeChanged]: [mode: string]
[RooCodeEventName.ProviderProfileChanged]: [config: { name: string; provider?: string }]
[RooCodeEventName.SwarmSessionStarted]: [sessionId: string, leaderTaskId: string]
[RooCodeEventName.SwarmSessionEnded]: [sessionId: string, leaderTaskId: string]
[RooCodeEventName.WorkerRegistered]: [sessionId: string, taskId: string, agentName: string, agentColor: string]
}
/**

View file

@ -0,0 +1,62 @@
import type { AgentColorName, AgentIdentity, SwarmSession } from "@roo-code/types"
const AGENT_COLORS: AgentColorName[] = ["blue", "green", "yellow", "purple", "orange", "pink", "cyan", "red"]
/**
* In-memory registry that tracks swarm sessions and assigns stable identities
* (name + color) to concurrent worker agents.
*
* One instance lives on ClineProvider. Sessions are created when
* spawnConcurrentChildren() starts and destroyed when all children resolve.
*/
export class SwarmRegistry {
private sessions: Map<string, SwarmSession> = new Map()
private colorIndex = 0
createSession(sessionId: string, leaderTaskId: string): SwarmSession {
const session: SwarmSession = { sessionId, leaderTaskId, teammates: {} }
this.sessions.set(sessionId, session)
return session
}
/** Round-robin color assignment, stable across the provider lifetime. */
assignColor(): AgentColorName {
const color = AGENT_COLORS[this.colorIndex % AGENT_COLORS.length]
this.colorIndex++
return color
}
registerWorker(sessionId: string, identity: AgentIdentity): void {
const session = this.sessions.get(sessionId)
if (session) {
session.teammates[identity.taskId] = identity
}
}
unregisterWorker(sessionId: string, taskId: string): void {
const session = this.sessions.get(sessionId)
if (session) {
delete session.teammates[taskId]
}
}
getSession(sessionId: string): SwarmSession | undefined {
return this.sessions.get(sessionId)
}
/** Find the session a given task belongs to (as a worker). */
getSessionForTask(taskId: string): SwarmSession | undefined {
for (const session of this.sessions.values()) {
if (taskId in session.teammates) return session
}
return undefined
}
destroySession(sessionId: string): void {
this.sessions.delete(sessionId)
}
dispose(): void {
this.sessions.clear()
}
}

View file

@ -31,7 +31,7 @@ interface TeamPhaseProvider {
}): Promise<Task>
spawnConcurrentChildren(params: {
parentTaskId: string
tasks: Array<{ mode: string; message: string; worktree?: string }>
tasks: Array<{ mode: string; message: string; worktree?: string; role?: string }>
abortOnChildFailure?: boolean
}): Promise<Array<{ taskId: string; summary: string; error?: string }>>
}
@ -127,7 +127,7 @@ export class RunTeamPhaseTool extends BaseTool<"run_team_phase"> {
// Concurrent: all agents start simultaneously; parent stays alive.
const results = await provider.spawnConcurrentChildren({
parentTaskId: task.taskId,
tasks: agentSpecs.map(({ mode, message, worktree }) => ({ mode, message, worktree })),
tasks: agentSpecs.map(({ mode, message, worktree, role }) => ({ mode, message, worktree, role })),
abortOnChildFailure: phase.abortOnChildFailure ?? false,
})
pushToolResult(JSON.stringify(results, null, 2))

View file

@ -78,6 +78,7 @@ import type { IndexProgressUpdate } from "../../services/code-index/interfaces/m
import { MdmService } from "../../services/mdm/MdmService"
import { SkillsManager } from "../../services/skills/SkillsManager"
import { TeamsManager } from "../../services/teams/TeamsManager"
import { SwarmRegistry } from "../swarm/SwarmRegistry"
import { fileExistsAtPath } from "../../utils/fs"
import { setTtsEnabled, setTtsSpeed } from "../../utils/tts"
@ -154,6 +155,7 @@ export class ClineProvider
protected mcpHub?: McpHub // Change from private to protected
protected skillsManager?: SkillsManager
protected teamsManager?: TeamsManager
protected swarmRegistry: SwarmRegistry = new SwarmRegistry()
private marketplaceManager: MarketplaceManager
private mdmService?: MdmService
private taskCreationCallback: (task: Task) => void
@ -741,6 +743,7 @@ export class ClineProvider
await this.skillsManager?.dispose()
this.skillsManager = undefined
this.teamsManager = undefined
this.swarmRegistry.dispose()
this.marketplaceManager?.cleanup()
this.customModesManager?.dispose()
this.taskHistoryStore.dispose()
@ -3823,7 +3826,7 @@ export class ClineProvider
*/
public async spawnConcurrentChildren(params: {
parentTaskId: string
tasks: Array<{ mode: string; message: string; worktree?: string; todos?: TodoItem[] }>
tasks: Array<{ mode: string; message: string; worktree?: string; todos?: TodoItem[]; role?: string }>
abortOnChildFailure?: boolean
}): Promise<Array<{ taskId: string; summary: string; payload?: Record<string, unknown>; error?: string }>> {
const { parentTaskId, tasks, abortOnChildFailure = false } = params
@ -3836,6 +3839,19 @@ export class ClineProvider
TelemetryService.instance.captureParallelTaskSpawned(parentTaskId, tasks.length)
this.log(`[spawnConcurrentChildren] Spawning ${tasks.length} concurrent children for parent ${parentTaskId}`)
// Create a swarm session so every worker gets a stable identity + color.
const sessionId = parentTaskId
this.swarmRegistry.createSession(sessionId, parentTaskId)
this.emit(RooCodeEventName.SwarmSessionStarted, sessionId, parentTaskId)
// Mark parent as swarm leader in its history item.
try {
const { historyItem: parentHistory } = await this.getTaskWithId(parentTaskId)
await this.updateTaskHistory({ ...parentHistory, swarmSessionId: sessionId, isSwarmLeader: true })
} catch {
// non-fatal
}
// Phase 1: sequential setup — mode switch and task creation must be serialised to
// prevent handleModeSwitch() from racing (it mutates global provider mode state).
// Task.start() is NOT called yet; that happens in phase 2.
@ -3851,7 +3867,9 @@ export class ClineProvider
}
const entries: ChildEntry[] = []
for (const spec of tasks) {
for (let i = 0; i < tasks.length; i++) {
const spec = tasks[i]
try {
await this.handleModeSwitch(spec.mode as any)
} catch {
@ -3870,13 +3888,32 @@ export class ClineProvider
workspacePath: childWorktreePath,
})
if (childWorktreePath) {
try {
const { historyItem: childHistory } = await this.getTaskWithId(child.taskId)
await this.updateTaskHistory({ ...childHistory, worktreePath: childWorktreePath })
} catch {
// non-fatal
}
// Assign swarm identity to this worker.
const agentName = spec.role ?? `worker-${i + 1}`
const agentColor = this.swarmRegistry.assignColor()
const agentId = `${agentName}@${sessionId}`
const identity: import("@roo-code/types").AgentIdentity = {
agentId,
agentName,
color: agentColor,
isLeader: false,
taskId: child.taskId,
}
this.swarmRegistry.registerWorker(sessionId, identity)
this.emit(RooCodeEventName.WorkerRegistered, sessionId, child.taskId, agentName, agentColor)
try {
const { historyItem: childHistory } = await this.getTaskWithId(child.taskId)
await this.updateTaskHistory({
...childHistory,
...(childWorktreePath ? { worktreePath: childWorktreePath } : {}),
swarmSessionId: sessionId,
agentId,
agentName,
agentColor,
})
} catch {
// non-fatal
}
const completionPromise = new Promise<{
@ -3947,6 +3984,9 @@ export class ClineProvider
`[spawnConcurrentChildren] All ${tasks.length} children completed for parent ${parentTaskId}. failures=${hadFailures}`,
)
this.swarmRegistry.destroySession(sessionId)
this.emit(RooCodeEventName.SwarmSessionEnded, sessionId, parentTaskId)
return results
}

View file

@ -7,6 +7,17 @@ import { countAllSubtasks } from "./types"
import { StandardTooltip } from "../ui"
import SubtaskCollapsibleRow from "./SubtaskCollapsibleRow"
const AGENT_COLOR_MAP: Record<string, string> = {
red: "#f87171",
blue: "#60a5fa",
green: "#4ade80",
yellow: "#facc15",
purple: "#c084fc",
orange: "#fb923c",
pink: "#f472b6",
cyan: "#22d3ee",
}
interface SubtaskRowProps {
/** The subtask tree node to display */
node: SubtaskTreeNode
@ -49,9 +60,19 @@ const SubtaskRow = ({ node, depth, onToggleExpand, className }: SubtaskRowProps)
handleClick()
}
}}>
<StandardTooltip content={item.task} delay={600}>
<span className="text-sm line-clamp-1">{item.task}</span>
</StandardTooltip>
<div className="flex items-center gap-1.5 min-w-0">
{item.agentColor && AGENT_COLOR_MAP[item.agentColor] && (
<StandardTooltip content={item.agentName ?? item.agentColor} delay={300}>
<span
className="size-2 rounded-full shrink-0"
style={{ backgroundColor: AGENT_COLOR_MAP[item.agentColor] }}
/>
</StandardTooltip>
)}
<StandardTooltip content={item.task} delay={600}>
<span className="text-sm line-clamp-1">{item.task}</span>
</StandardTooltip>
</div>
<ArrowRight className="size-3 opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
</div>