mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-10-25 05:53:57 +00:00
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:
parent
b8e2e83a3f
commit
10b6690672
9 changed files with 229 additions and 14 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
55
packages/types/src/swarm.ts
Normal file
55
packages/types/src/swarm.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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]
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
62
src/core/swarm/SwarmRegistry.ts
Normal file
62
src/core/swarm/SwarmRegistry.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue