@mclawnet/swarm 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/action-parser.d.ts +16 -0
- package/dist/action-parser.d.ts.map +1 -0
- package/dist/action-parser.js +81 -0
- package/dist/action-parser.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/message-router.d.ts +33 -0
- package/dist/message-router.d.ts.map +1 -0
- package/dist/message-router.js +54 -0
- package/dist/message-router.js.map +1 -0
- package/dist/persistence.d.ts +23 -0
- package/dist/persistence.d.ts.map +1 -0
- package/dist/persistence.js +61 -0
- package/dist/persistence.js.map +1 -0
- package/dist/recovery.d.ts +5 -0
- package/dist/recovery.d.ts.map +1 -0
- package/dist/recovery.js +36 -0
- package/dist/recovery.js.map +1 -0
- package/dist/roles/role-loader.d.ts +16 -0
- package/dist/roles/role-loader.d.ts.map +1 -0
- package/dist/roles/role-loader.js +103 -0
- package/dist/roles/role-loader.js.map +1 -0
- package/dist/roles/types.d.ts +18 -0
- package/dist/roles/types.d.ts.map +1 -0
- package/dist/roles/types.js +2 -0
- package/dist/roles/types.js.map +1 -0
- package/dist/swarm-coordinator.d.ts +69 -0
- package/dist/swarm-coordinator.d.ts.map +1 -0
- package/dist/swarm-coordinator.js +637 -0
- package/dist/swarm-coordinator.js.map +1 -0
- package/dist/types.d.ts +123 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +31 -0
- package/roles/developer.md +53 -0
- package/roles/queen.md +136 -0
- package/roles/reviewer.md +59 -0
- package/roles/tester.md +53 -0
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
import { loadRole, buildRolePrompt } from "./roles/role-loader.js";
|
|
2
|
+
import { LocalMessageRouter } from "./message-router.js";
|
|
3
|
+
import { parseSwarmActions, parsePlanFromText } from "./action-parser.js";
|
|
4
|
+
import { buildMemorySection } from "@mclawnet/memory";
|
|
5
|
+
import { saveSwarmSnapshot, deleteSwarmSnapshot, appendMessageLog } from "./persistence.js";
|
|
6
|
+
import { createLogger } from "@mclawnet/logger";
|
|
7
|
+
const log = createLogger({ module: "swarm" });
|
|
8
|
+
const QUEEN_CHECK_INTERVAL_MS = 300_000; // 5min when workers active
|
|
9
|
+
const QUEEN_CHECK_IDLE_INTERVAL_MS = 300_000; // 5min when all roles idle
|
|
10
|
+
/**
|
|
11
|
+
* SwarmCoordinator — orchestrates multi-role Claude CLI swarms.
|
|
12
|
+
*
|
|
13
|
+
* Composes (not replaces) SessionAdapter. Each role instance is a separate
|
|
14
|
+
* session in SessionAdapter. Communication between roles goes through
|
|
15
|
+
* MessageRouter (currently LocalMessageRouter using stdin/stdout).
|
|
16
|
+
*/
|
|
17
|
+
export class SwarmCoordinator {
|
|
18
|
+
sessionAdapter;
|
|
19
|
+
hub;
|
|
20
|
+
swarms = new Map();
|
|
21
|
+
constructor(sessionAdapter, hub) {
|
|
22
|
+
this.sessionAdapter = sessionAdapter;
|
|
23
|
+
this.hub = hub;
|
|
24
|
+
}
|
|
25
|
+
// ── Public API ──────────────────────────────────────────────────────
|
|
26
|
+
/** Create a new swarm and spawn initial roles. */
|
|
27
|
+
async create(swarmSessionId, options) {
|
|
28
|
+
if (this.swarms.has(swarmSessionId)) {
|
|
29
|
+
throw new Error(`Swarm ${swarmSessionId} already exists`);
|
|
30
|
+
}
|
|
31
|
+
const router = new LocalMessageRouter(this.sessionAdapter);
|
|
32
|
+
const swarm = {
|
|
33
|
+
id: swarmSessionId,
|
|
34
|
+
hubSessionId: swarmSessionId,
|
|
35
|
+
workDir: options.workDir,
|
|
36
|
+
roles: new Map(),
|
|
37
|
+
plan: null,
|
|
38
|
+
nextInstanceSeq: new Map(),
|
|
39
|
+
idleCheckCount: 0,
|
|
40
|
+
maxIdleChecks: 10,
|
|
41
|
+
isPaused: false,
|
|
42
|
+
status: "creating",
|
|
43
|
+
planStatus: "none",
|
|
44
|
+
};
|
|
45
|
+
// Attach the router to the swarm for internal access
|
|
46
|
+
swarm._router = router;
|
|
47
|
+
this.swarms.set(swarmSessionId, swarm);
|
|
48
|
+
// Spawn all initial roles
|
|
49
|
+
for (const roleSpec of options.roles) {
|
|
50
|
+
const count = roleSpec.count ?? 1;
|
|
51
|
+
for (let i = 0; i < count; i++) {
|
|
52
|
+
await this.spawnRole(swarmSessionId, roleSpec.roleName, router, undefined, roleSpec.customPrompt, roleSpec.customDefinition);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// Start Queen periodic check
|
|
56
|
+
this.startQueenCheck(swarmSessionId);
|
|
57
|
+
// Mark swarm as running after all roles spawned
|
|
58
|
+
swarm.status = "running";
|
|
59
|
+
// If there's an initial task, send it to Queen
|
|
60
|
+
if (options.task) {
|
|
61
|
+
const queen = this.findQueen(swarm);
|
|
62
|
+
if (queen) {
|
|
63
|
+
router.send(queen.instanceId, {
|
|
64
|
+
from: "user",
|
|
65
|
+
type: "task",
|
|
66
|
+
data: options.task,
|
|
67
|
+
timestamp: Date.now(),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
log.info({ swarmId: swarmSessionId, roleCount: swarm.roles.size }, "swarm created");
|
|
72
|
+
// Persistence: save initial snapshot
|
|
73
|
+
saveSwarmSnapshot(swarm);
|
|
74
|
+
}
|
|
75
|
+
/** Handle user message directed at the swarm. */
|
|
76
|
+
async handleUserMessage(swarmSessionId, content, targetInstance) {
|
|
77
|
+
const swarm = this.swarms.get(swarmSessionId);
|
|
78
|
+
if (!swarm) {
|
|
79
|
+
log.error({ swarmId: swarmSessionId }, "handleUserMessage: swarm not found");
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const router = swarm._router;
|
|
83
|
+
const message = {
|
|
84
|
+
from: "user",
|
|
85
|
+
type: "task",
|
|
86
|
+
data: content,
|
|
87
|
+
timestamp: Date.now(),
|
|
88
|
+
};
|
|
89
|
+
// Auto-resume if paused
|
|
90
|
+
if (swarm.status === "paused") {
|
|
91
|
+
swarm.status = "running";
|
|
92
|
+
swarm.isPaused = false;
|
|
93
|
+
swarm.idleCheckCount = 0;
|
|
94
|
+
this.startQueenCheck(swarmSessionId);
|
|
95
|
+
log.info({ swarmId: swarmSessionId }, "swarm resumed");
|
|
96
|
+
}
|
|
97
|
+
if (targetInstance) {
|
|
98
|
+
// Directed at a specific instance
|
|
99
|
+
router.send(targetInstance, message);
|
|
100
|
+
appendMessageLog(swarmSessionId, { type: "user_message", from: "user", to: targetInstance, data: content, timestamp: Date.now() });
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
// Default: send to Queen
|
|
104
|
+
const queen = this.findQueen(swarm);
|
|
105
|
+
if (queen) {
|
|
106
|
+
router.send(queen.instanceId, message);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
// No queen — broadcast to all
|
|
110
|
+
router.broadcast(message);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Handle output from a role's Claude CLI process.
|
|
116
|
+
* Called by the onOutput callback wired in start.ts.
|
|
117
|
+
*
|
|
118
|
+
* Returns true if the sessionId belongs to a swarm role.
|
|
119
|
+
*/
|
|
120
|
+
handleRoleOutput(roleSessionId, data) {
|
|
121
|
+
const { swarm, role } = this.findByRoleSessionId(roleSessionId);
|
|
122
|
+
if (!swarm || !role)
|
|
123
|
+
return false;
|
|
124
|
+
const router = swarm._router;
|
|
125
|
+
// Extract text content from the streaming event
|
|
126
|
+
const text = extractTextFromEvent(data);
|
|
127
|
+
// Parse swarm action blocks
|
|
128
|
+
if (text) {
|
|
129
|
+
// Log when assistant event contains meaningful text (not every streaming chunk)
|
|
130
|
+
if (text.includes("```swarm")) {
|
|
131
|
+
log.info({ instanceId: role.instanceId, textLen: text.length }, "assistant output contains swarm block");
|
|
132
|
+
}
|
|
133
|
+
// For queen: parse plan BEFORE executing actions, so review can gate task assignment
|
|
134
|
+
if (role.definition.type === "queen") {
|
|
135
|
+
const hasPlanKeyword = text.includes('"plan"');
|
|
136
|
+
if (hasPlanKeyword) {
|
|
137
|
+
log.info({ instanceId: role.instanceId, textLen: text.length, hasPlanKeyword }, "queen output contains plan keyword, attempting parse");
|
|
138
|
+
}
|
|
139
|
+
const plan = parsePlanFromText(text);
|
|
140
|
+
if (plan) {
|
|
141
|
+
swarm.plan = plan;
|
|
142
|
+
swarm.planStatus = "draft";
|
|
143
|
+
saveSwarmSnapshot(swarm);
|
|
144
|
+
this.sendStatusUpdate(swarm);
|
|
145
|
+
log.info({ swarmId: swarm.id, instanceId: role.instanceId }, "plan updated (draft)");
|
|
146
|
+
this.requestPlanReview(swarm, plan);
|
|
147
|
+
}
|
|
148
|
+
else if (hasPlanKeyword) {
|
|
149
|
+
log.warn({ instanceId: role.instanceId, textSnippet: text.substring(0, 500) }, "queen output has plan keyword but parsePlanFromText returned null");
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const { actions } = parseSwarmActions(text);
|
|
153
|
+
if (text.includes("```swarm") && actions.length === 0) {
|
|
154
|
+
log.warn({ instanceId: role.instanceId, textSnippet: text.substring(0, 500) }, "swarm block found but parse failed");
|
|
155
|
+
}
|
|
156
|
+
for (const action of actions) {
|
|
157
|
+
this.executeAction(swarm, role, router, action);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// Forward full output to Hub for UI display + DB storage
|
|
161
|
+
this.hub.send({
|
|
162
|
+
type: "swarm.output",
|
|
163
|
+
sessionId: swarm.hubSessionId,
|
|
164
|
+
instanceId: role.instanceId,
|
|
165
|
+
roleName: role.roleName,
|
|
166
|
+
data,
|
|
167
|
+
});
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Handle turn completion from a role's Claude CLI process.
|
|
172
|
+
* Returns true if the sessionId belongs to a swarm role.
|
|
173
|
+
*/
|
|
174
|
+
handleRoleTurnComplete(roleSessionId, info) {
|
|
175
|
+
const { swarm, role } = this.findByRoleSessionId(roleSessionId);
|
|
176
|
+
if (!swarm || !role)
|
|
177
|
+
return false;
|
|
178
|
+
// Update role status
|
|
179
|
+
role.status = "idle";
|
|
180
|
+
// Forward to Hub
|
|
181
|
+
this.hub.send({
|
|
182
|
+
type: "swarm.turn_complete",
|
|
183
|
+
sessionId: swarm.hubSessionId,
|
|
184
|
+
instanceId: role.instanceId,
|
|
185
|
+
roleName: role.roleName,
|
|
186
|
+
cost: info.cost,
|
|
187
|
+
duration: info.duration,
|
|
188
|
+
});
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
/** Spawn a new role instance in a swarm. */
|
|
192
|
+
async spawnRole(swarmId, roleName, router, taskPrompt, customPrompt, customDefinition) {
|
|
193
|
+
const swarm = this.swarms.get(swarmId);
|
|
194
|
+
if (!swarm)
|
|
195
|
+
throw new Error(`Swarm ${swarmId} not found`);
|
|
196
|
+
// Use custom definition directly when provided; otherwise load from disk
|
|
197
|
+
let definition;
|
|
198
|
+
if (customDefinition) {
|
|
199
|
+
definition = {
|
|
200
|
+
name: customDefinition.name,
|
|
201
|
+
shortName: customDefinition.shortName,
|
|
202
|
+
type: "worker",
|
|
203
|
+
description: customDefinition.description,
|
|
204
|
+
capabilities: customDefinition.capabilities,
|
|
205
|
+
color: customDefinition.color,
|
|
206
|
+
promptBody: customDefinition.promptBody,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
definition = loadRole(roleName);
|
|
211
|
+
}
|
|
212
|
+
// If a custom prompt was provided, override the definition's promptBody
|
|
213
|
+
if (customPrompt) {
|
|
214
|
+
definition.promptBody = customPrompt;
|
|
215
|
+
}
|
|
216
|
+
const seq = swarm.nextInstanceSeq.get(roleName) ?? 0;
|
|
217
|
+
swarm.nextInstanceSeq.set(roleName, seq + 1);
|
|
218
|
+
const instanceId = `${definition.shortName}-${seq}`;
|
|
219
|
+
const roleSessionId = `${swarmId}::${instanceId}`;
|
|
220
|
+
const roleInstance = {
|
|
221
|
+
instanceId,
|
|
222
|
+
roleName,
|
|
223
|
+
definition,
|
|
224
|
+
roleSessionId,
|
|
225
|
+
status: "spawning",
|
|
226
|
+
currentTask: taskPrompt,
|
|
227
|
+
};
|
|
228
|
+
swarm.roles.set(instanceId, roleInstance);
|
|
229
|
+
// Build role list for prompt
|
|
230
|
+
const roleList = this.buildRoleListString(swarm);
|
|
231
|
+
const systemPrompt = buildRolePrompt(definition, instanceId, roleList);
|
|
232
|
+
const memorySection = buildMemorySection(swarm.workDir, roleName);
|
|
233
|
+
const fullPrompt = memorySection ? `${memorySection}\n\n${systemPrompt}` : systemPrompt;
|
|
234
|
+
// Spawn Claude CLI process via SessionAdapter
|
|
235
|
+
await this.sessionAdapter.createSession({
|
|
236
|
+
sessionId: roleSessionId,
|
|
237
|
+
workDir: swarm.workDir,
|
|
238
|
+
systemPrompt: fullPrompt,
|
|
239
|
+
});
|
|
240
|
+
roleInstance.status = "active";
|
|
241
|
+
// Persistence: save snapshot after role spawned
|
|
242
|
+
saveSwarmSnapshot(swarm);
|
|
243
|
+
// Register in router
|
|
244
|
+
const r = router ?? swarm._router;
|
|
245
|
+
r.register(instanceId, { kind: "local", roleSessionId });
|
|
246
|
+
// Send swarm status update to Hub
|
|
247
|
+
this.sendStatusUpdate(swarm);
|
|
248
|
+
log.info({ swarmId, instanceId, roleName }, "role spawned");
|
|
249
|
+
return roleInstance;
|
|
250
|
+
}
|
|
251
|
+
/** Stop a role instance. */
|
|
252
|
+
async stopRole(swarmId, instanceId) {
|
|
253
|
+
const swarm = this.swarms.get(swarmId);
|
|
254
|
+
if (!swarm)
|
|
255
|
+
return;
|
|
256
|
+
const role = swarm.roles.get(instanceId);
|
|
257
|
+
if (!role)
|
|
258
|
+
return;
|
|
259
|
+
role.status = "stopped";
|
|
260
|
+
const router = swarm._router;
|
|
261
|
+
router.unregister(instanceId);
|
|
262
|
+
await this.sessionAdapter.closeSession(role.roleSessionId);
|
|
263
|
+
swarm.roles.delete(instanceId);
|
|
264
|
+
// Persistence: save snapshot after role stopped
|
|
265
|
+
saveSwarmSnapshot(swarm);
|
|
266
|
+
this.sendStatusUpdate(swarm);
|
|
267
|
+
log.info({ swarmId, instanceId }, "role stopped");
|
|
268
|
+
}
|
|
269
|
+
/** Destroy an entire swarm. */
|
|
270
|
+
async destroy(swarmId) {
|
|
271
|
+
const swarm = this.swarms.get(swarmId);
|
|
272
|
+
if (!swarm)
|
|
273
|
+
return;
|
|
274
|
+
if (swarm.checkTimer)
|
|
275
|
+
clearTimeout(swarm.checkTimer);
|
|
276
|
+
for (const role of swarm.roles.values()) {
|
|
277
|
+
await this.sessionAdapter.closeSession(role.roleSessionId).catch(() => { });
|
|
278
|
+
}
|
|
279
|
+
this.swarms.delete(swarmId);
|
|
280
|
+
// Persistence: remove snapshot
|
|
281
|
+
deleteSwarmSnapshot(swarmId);
|
|
282
|
+
log.info({ swarmId }, "swarm destroyed");
|
|
283
|
+
}
|
|
284
|
+
/** Check if a session ID belongs to any swarm. */
|
|
285
|
+
isSwarmSession(sessionId) {
|
|
286
|
+
return sessionId.includes("::");
|
|
287
|
+
}
|
|
288
|
+
/** Check if a hub session ID is a swarm. */
|
|
289
|
+
hasSwarm(swarmSessionId) {
|
|
290
|
+
return this.swarms.has(swarmSessionId);
|
|
291
|
+
}
|
|
292
|
+
/** Get a swarm instance by hub session ID. */
|
|
293
|
+
getSwarm(swarmSessionId) {
|
|
294
|
+
return this.swarms.get(swarmSessionId);
|
|
295
|
+
}
|
|
296
|
+
/** Mark a swarm as completed. Releases all resources — closes sessions, deletes snapshot, removes from memory. */
|
|
297
|
+
async complete(swarmId) {
|
|
298
|
+
const swarm = this.swarms.get(swarmId);
|
|
299
|
+
if (!swarm)
|
|
300
|
+
return;
|
|
301
|
+
swarm.status = "completed";
|
|
302
|
+
this.sendStatusUpdate(swarm);
|
|
303
|
+
// Release all resources
|
|
304
|
+
if (swarm.checkTimer) {
|
|
305
|
+
clearTimeout(swarm.checkTimer);
|
|
306
|
+
swarm.checkTimer = undefined;
|
|
307
|
+
}
|
|
308
|
+
for (const role of swarm.roles.values()) {
|
|
309
|
+
await this.sessionAdapter.closeSession(role.roleSessionId).catch(() => { });
|
|
310
|
+
}
|
|
311
|
+
this.swarms.delete(swarmId);
|
|
312
|
+
deleteSwarmSnapshot(swarmId);
|
|
313
|
+
log.info({ swarmId }, "swarm completed — all resources released");
|
|
314
|
+
}
|
|
315
|
+
/** Mark a swarm as failed. Releases all resources — closes sessions, deletes snapshot, removes from memory. */
|
|
316
|
+
async fail(swarmId) {
|
|
317
|
+
const swarm = this.swarms.get(swarmId);
|
|
318
|
+
if (!swarm)
|
|
319
|
+
return;
|
|
320
|
+
swarm.status = "failed";
|
|
321
|
+
this.sendStatusUpdate(swarm);
|
|
322
|
+
// Release all resources
|
|
323
|
+
if (swarm.checkTimer) {
|
|
324
|
+
clearTimeout(swarm.checkTimer);
|
|
325
|
+
swarm.checkTimer = undefined;
|
|
326
|
+
}
|
|
327
|
+
for (const role of swarm.roles.values()) {
|
|
328
|
+
await this.sessionAdapter.closeSession(role.roleSessionId).catch(() => { });
|
|
329
|
+
}
|
|
330
|
+
this.swarms.delete(swarmId);
|
|
331
|
+
deleteSwarmSnapshot(swarmId);
|
|
332
|
+
log.info({ swarmId }, "swarm failed — all resources released");
|
|
333
|
+
}
|
|
334
|
+
// ── Private helpers ─────────────────────────────────────────────────
|
|
335
|
+
findQueen(swarm) {
|
|
336
|
+
for (const role of swarm.roles.values()) {
|
|
337
|
+
if (role.definition.type === "queen" && role.status !== "stopped")
|
|
338
|
+
return role;
|
|
339
|
+
}
|
|
340
|
+
return undefined;
|
|
341
|
+
}
|
|
342
|
+
findReviewer(swarm) {
|
|
343
|
+
for (const role of swarm.roles.values()) {
|
|
344
|
+
if (role.roleName === "reviewer" && role.status !== "stopped")
|
|
345
|
+
return role;
|
|
346
|
+
}
|
|
347
|
+
return undefined;
|
|
348
|
+
}
|
|
349
|
+
/** Send plan to reviewer for review. Auto-approves if no reviewer available. */
|
|
350
|
+
requestPlanReview(swarm, plan) {
|
|
351
|
+
const reviewer = this.findReviewer(swarm);
|
|
352
|
+
if (!reviewer) {
|
|
353
|
+
// No reviewer — auto-approve
|
|
354
|
+
swarm.planStatus = "approved";
|
|
355
|
+
saveSwarmSnapshot(swarm);
|
|
356
|
+
this.sendStatusUpdate(swarm);
|
|
357
|
+
log.info({ swarmId: swarm.id }, "plan auto-approved (no reviewer)");
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
swarm.planStatus = "reviewing";
|
|
361
|
+
saveSwarmSnapshot(swarm);
|
|
362
|
+
this.sendStatusUpdate(swarm);
|
|
363
|
+
const router = swarm._router;
|
|
364
|
+
const planJson = JSON.stringify(plan, null, 2);
|
|
365
|
+
router.send(reviewer.instanceId, {
|
|
366
|
+
from: "system",
|
|
367
|
+
type: "plan_review",
|
|
368
|
+
data: `[系统] 请审查以下执行计划,评估其可行性和风险。
|
|
369
|
+
|
|
370
|
+
审查要点:
|
|
371
|
+
1. 任务拆解是否合理?是否有遗漏的步骤?
|
|
372
|
+
2. 依赖关系是否正确?是否可以更多并行?
|
|
373
|
+
3. 角色分配是否合适?
|
|
374
|
+
4. 是否存在明显的风险或失败点?
|
|
375
|
+
|
|
376
|
+
计划内容:
|
|
377
|
+
\`\`\`json
|
|
378
|
+
${planJson}
|
|
379
|
+
\`\`\`
|
|
380
|
+
|
|
381
|
+
审查完成后,请用 report action 回复(taskId="plan_review"):
|
|
382
|
+
- 如果批准:status="completed",output 中包含 "approved" 和你的审查意见
|
|
383
|
+
- 如果拒绝:status="failed",output 中说明拒绝原因和改进建议`,
|
|
384
|
+
taskId: "plan_review",
|
|
385
|
+
timestamp: Date.now(),
|
|
386
|
+
});
|
|
387
|
+
log.info({ swarmId: swarm.id, reviewer: reviewer.instanceId }, "plan sent to reviewer");
|
|
388
|
+
}
|
|
389
|
+
/** Handle plan review result from reviewer. */
|
|
390
|
+
handlePlanReviewResult(swarm, fromRole, action) {
|
|
391
|
+
const router = swarm._router;
|
|
392
|
+
const queen = this.findQueen(swarm);
|
|
393
|
+
if (action.status === "completed" && action.output?.toLowerCase().includes("approved")) {
|
|
394
|
+
swarm.planStatus = "approved";
|
|
395
|
+
saveSwarmSnapshot(swarm);
|
|
396
|
+
this.sendStatusUpdate(swarm);
|
|
397
|
+
log.info({ swarmId: swarm.id, reviewer: fromRole.instanceId }, "plan approved by reviewer");
|
|
398
|
+
// Notify queen to start task assignment
|
|
399
|
+
if (queen) {
|
|
400
|
+
router.send(queen.instanceId, {
|
|
401
|
+
from: "system",
|
|
402
|
+
type: "plan_approved",
|
|
403
|
+
data: `[系统] 你的执行计划已通过审查。审查意见:${action.output}\n\n请开始按计划分配任务。`,
|
|
404
|
+
timestamp: Date.now(),
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
else {
|
|
409
|
+
swarm.planStatus = "rejected";
|
|
410
|
+
saveSwarmSnapshot(swarm);
|
|
411
|
+
this.sendStatusUpdate(swarm);
|
|
412
|
+
log.info({ swarmId: swarm.id, reviewer: fromRole.instanceId }, "plan rejected by reviewer");
|
|
413
|
+
// Notify queen to revise the plan
|
|
414
|
+
if (queen) {
|
|
415
|
+
router.send(queen.instanceId, {
|
|
416
|
+
from: "system",
|
|
417
|
+
type: "plan_rejected",
|
|
418
|
+
data: `[系统] 你的执行计划未通过审查,请根据反馈修改。\n\n审查反馈:${action.output ?? "未提供具体原因"}`,
|
|
419
|
+
timestamp: Date.now(),
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
findByRoleSessionId(roleSessionId) {
|
|
425
|
+
// roleSessionId format: "{swarmId}::{instanceId}"
|
|
426
|
+
const sep = roleSessionId.indexOf("::");
|
|
427
|
+
if (sep === -1)
|
|
428
|
+
return {};
|
|
429
|
+
const swarmId = roleSessionId.slice(0, sep);
|
|
430
|
+
const instanceId = roleSessionId.slice(sep + 2);
|
|
431
|
+
const swarm = this.swarms.get(swarmId);
|
|
432
|
+
if (!swarm)
|
|
433
|
+
return {};
|
|
434
|
+
const role = swarm.roles.get(instanceId);
|
|
435
|
+
return { swarm, role };
|
|
436
|
+
}
|
|
437
|
+
executeAction(swarm, fromRole, router, action) {
|
|
438
|
+
log.info({ swarmId: swarm.id, from: fromRole.instanceId, action: action.action }, "executing action");
|
|
439
|
+
switch (action.action) {
|
|
440
|
+
case "send": {
|
|
441
|
+
// Gate: queen cannot send task to workers while plan is under review
|
|
442
|
+
if (fromRole.definition.type === "queen" &&
|
|
443
|
+
action.type === "task" &&
|
|
444
|
+
(swarm.planStatus === "draft" || swarm.planStatus === "reviewing")) {
|
|
445
|
+
log.info({ swarmId: swarm.id, to: action.to, planStatus: swarm.planStatus }, "queen task send blocked: plan under review");
|
|
446
|
+
const router2 = swarm._router;
|
|
447
|
+
router2.send(fromRole.instanceId, {
|
|
448
|
+
from: "system",
|
|
449
|
+
type: "system",
|
|
450
|
+
data: `[系统] 任务分配被拦截。当前计划状态为「${swarm.planStatus}」,请等待审查完成后再分配任务。`,
|
|
451
|
+
timestamp: Date.now(),
|
|
452
|
+
});
|
|
453
|
+
break;
|
|
454
|
+
}
|
|
455
|
+
const msg = {
|
|
456
|
+
from: fromRole.instanceId,
|
|
457
|
+
type: action.type,
|
|
458
|
+
data: action.data,
|
|
459
|
+
taskId: action.taskId,
|
|
460
|
+
timestamp: Date.now(),
|
|
461
|
+
};
|
|
462
|
+
router.send(action.to, msg);
|
|
463
|
+
appendMessageLog(swarm.id, { type: "action", action: "send", from: fromRole.instanceId, to: action.to, data: action.data, timestamp: Date.now() });
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
case "report": {
|
|
467
|
+
// Update role status
|
|
468
|
+
if (action.status === "completed" || action.status === "failed") {
|
|
469
|
+
fromRole.currentTask = undefined;
|
|
470
|
+
}
|
|
471
|
+
// Check for plan review result
|
|
472
|
+
if (action.taskId === "plan_review") {
|
|
473
|
+
this.handlePlanReviewResult(swarm, fromRole, action);
|
|
474
|
+
break;
|
|
475
|
+
}
|
|
476
|
+
// Notify queen about the report (so she can coordinate next steps)
|
|
477
|
+
const queen = this.findQueen(swarm);
|
|
478
|
+
if (queen && queen.instanceId !== fromRole.instanceId) {
|
|
479
|
+
const reportMsg = {
|
|
480
|
+
from: fromRole.instanceId,
|
|
481
|
+
type: "report",
|
|
482
|
+
data: `[${action.status}] ${action.output ?? "任务状态更新"}`,
|
|
483
|
+
taskId: action.taskId,
|
|
484
|
+
timestamp: Date.now(),
|
|
485
|
+
};
|
|
486
|
+
router.send(queen.instanceId, reportMsg);
|
|
487
|
+
appendMessageLog(swarm.id, { type: "action", action: "report", from: fromRole.instanceId, to: queen.instanceId, status: action.status, data: action.output, timestamp: Date.now() });
|
|
488
|
+
log.info({ swarmId: swarm.id, from: fromRole.instanceId, status: action.status, taskId: action.taskId }, "report forwarded to queen");
|
|
489
|
+
}
|
|
490
|
+
// Queen reporting completed/failed = swarm lifecycle transition
|
|
491
|
+
if (fromRole.definition.type === "queen") {
|
|
492
|
+
if (action.status === "completed") {
|
|
493
|
+
this.complete(swarm.id).catch((err) => {
|
|
494
|
+
log.error({ err, swarmId: swarm.id }, "failed to complete swarm");
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
else if (action.status === "failed") {
|
|
498
|
+
this.fail(swarm.id).catch((err) => {
|
|
499
|
+
log.error({ err, swarmId: swarm.id }, "failed to mark swarm as failed");
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
// Forward status to Hub
|
|
504
|
+
this.sendStatusUpdate(swarm);
|
|
505
|
+
break;
|
|
506
|
+
}
|
|
507
|
+
case "broadcast": {
|
|
508
|
+
const msg = {
|
|
509
|
+
from: fromRole.instanceId,
|
|
510
|
+
type: "broadcast",
|
|
511
|
+
data: action.data,
|
|
512
|
+
timestamp: Date.now(),
|
|
513
|
+
};
|
|
514
|
+
router.broadcast(msg, fromRole.instanceId);
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
517
|
+
case "spawn_role": {
|
|
518
|
+
// Only queen can spawn roles
|
|
519
|
+
if (fromRole.definition.type === "queen") {
|
|
520
|
+
this.spawnRole(swarm.id, action.roleName, router, action.task).catch((err) => {
|
|
521
|
+
log.error({ err, roleName: action.roleName }, "failed to spawn role");
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
break;
|
|
525
|
+
}
|
|
526
|
+
case "stop_role": {
|
|
527
|
+
// Only queen can stop roles
|
|
528
|
+
if (fromRole.definition.type === "queen") {
|
|
529
|
+
this.stopRole(swarm.id, action.instanceId).catch((err) => {
|
|
530
|
+
log.error({ err, instanceId: action.instanceId }, "failed to stop role");
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
break;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
startQueenCheck(swarmId) {
|
|
538
|
+
const swarm = this.swarms.get(swarmId);
|
|
539
|
+
if (!swarm)
|
|
540
|
+
return;
|
|
541
|
+
const tick = () => {
|
|
542
|
+
// Stop ticking if swarm is no longer active
|
|
543
|
+
if (swarm.status === "completed" || swarm.status === "failed" || swarm.status === "paused") {
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
const queen = this.findQueen(swarm);
|
|
547
|
+
if (!queen || queen.status === "stopped")
|
|
548
|
+
return;
|
|
549
|
+
const router = swarm._router;
|
|
550
|
+
// Adaptive interval: slow down when only queen is left or all roles idle
|
|
551
|
+
const activeWorkers = [...swarm.roles.values()].filter((r) => r.definition.type !== "queen" && r.status === "active");
|
|
552
|
+
// Track consecutive idle checks
|
|
553
|
+
if (activeWorkers.length > 0) {
|
|
554
|
+
swarm.idleCheckCount = 0;
|
|
555
|
+
}
|
|
556
|
+
else {
|
|
557
|
+
swarm.idleCheckCount++;
|
|
558
|
+
}
|
|
559
|
+
// Auto-pause if idle too long
|
|
560
|
+
if (swarm.idleCheckCount >= swarm.maxIdleChecks) {
|
|
561
|
+
swarm.isPaused = true;
|
|
562
|
+
swarm.status = "paused";
|
|
563
|
+
saveSwarmSnapshot(swarm);
|
|
564
|
+
this.sendStatusUpdate(swarm);
|
|
565
|
+
router.send(queen.instanceId, {
|
|
566
|
+
from: "system",
|
|
567
|
+
type: "system",
|
|
568
|
+
data: `[系统] 蜂群已自动暂停。连续${swarm.maxIdleChecks}次巡查发现所有成员空闲,已暂停任务释放资源。用户可以resume恢复。`,
|
|
569
|
+
timestamp: Date.now(),
|
|
570
|
+
});
|
|
571
|
+
log.info({ swarmId, idleChecks: swarm.maxIdleChecks }, "swarm auto-paused");
|
|
572
|
+
return; // Don't schedule next tick
|
|
573
|
+
}
|
|
574
|
+
const statusJson = JSON.stringify(this.buildStatusPayload(swarm), null, 2);
|
|
575
|
+
router.send(queen.instanceId, {
|
|
576
|
+
from: "system",
|
|
577
|
+
type: "system",
|
|
578
|
+
data: `[系统] 定时巡查触发。请查看当前蜂群状态,判断是否需要采取行动。\n\n当前蜂群成员及状态:\n${statusJson}`,
|
|
579
|
+
timestamp: Date.now(),
|
|
580
|
+
});
|
|
581
|
+
const nextInterval = activeWorkers.length > 0
|
|
582
|
+
? QUEEN_CHECK_INTERVAL_MS
|
|
583
|
+
: QUEEN_CHECK_IDLE_INTERVAL_MS;
|
|
584
|
+
swarm.checkTimer = setTimeout(tick, nextInterval);
|
|
585
|
+
};
|
|
586
|
+
swarm.checkTimer = setTimeout(tick, QUEEN_CHECK_INTERVAL_MS);
|
|
587
|
+
}
|
|
588
|
+
buildRoleListString(swarm) {
|
|
589
|
+
const lines = [];
|
|
590
|
+
for (const role of swarm.roles.values()) {
|
|
591
|
+
lines.push(`- ${role.instanceId} (${role.roleName}, ${role.definition.type}, status: ${role.status})`);
|
|
592
|
+
}
|
|
593
|
+
return lines.join("\n") || "(尚无成员)";
|
|
594
|
+
}
|
|
595
|
+
buildStatusPayload(swarm) {
|
|
596
|
+
return {
|
|
597
|
+
swarmStatus: swarm.status,
|
|
598
|
+
planStatus: swarm.planStatus,
|
|
599
|
+
roles: [...swarm.roles.values()].map((r) => ({
|
|
600
|
+
instanceId: r.instanceId,
|
|
601
|
+
roleName: r.roleName,
|
|
602
|
+
type: r.definition.type,
|
|
603
|
+
status: r.status,
|
|
604
|
+
currentTask: r.currentTask,
|
|
605
|
+
color: r.definition.color,
|
|
606
|
+
})),
|
|
607
|
+
plan: swarm.plan,
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
sendStatusUpdate(swarm) {
|
|
611
|
+
const payload = this.buildStatusPayload(swarm);
|
|
612
|
+
this.hub.send({
|
|
613
|
+
type: "swarm.status",
|
|
614
|
+
sessionId: swarm.hubSessionId,
|
|
615
|
+
swarmStatus: payload.swarmStatus,
|
|
616
|
+
planStatus: payload.planStatus,
|
|
617
|
+
roles: payload.roles,
|
|
618
|
+
plan: payload.plan,
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
623
|
+
/** Extract text content from a Claude stream-json event. */
|
|
624
|
+
function extractTextFromEvent(data) {
|
|
625
|
+
if (!data || typeof data !== "object")
|
|
626
|
+
return null;
|
|
627
|
+
const event = data;
|
|
628
|
+
if (event.type !== "assistant" || !event.message?.content)
|
|
629
|
+
return null;
|
|
630
|
+
let text = "";
|
|
631
|
+
for (const block of event.message.content) {
|
|
632
|
+
if (block.type === "text")
|
|
633
|
+
text += block.text;
|
|
634
|
+
}
|
|
635
|
+
return text || null;
|
|
636
|
+
}
|
|
637
|
+
//# sourceMappingURL=swarm-coordinator.js.map
|