@llblab/pi-actors 0.20.2 → 0.22.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/BACKLOG.md +34 -80
- package/CHANGELOG.md +29 -0
- package/README.md +7 -1
- package/dist/index.js +11 -0
- package/dist/lib/actor-rooms.d.ts +1 -0
- package/dist/lib/actor-rooms.js +33 -1
- package/dist/lib/async-runs.d.ts +35 -1
- package/dist/lib/async-runs.js +318 -36
- package/dist/lib/command-templates.js +8 -1
- package/dist/lib/observability.d.ts +15 -0
- package/dist/lib/observability.js +103 -18
- package/dist/lib/recipe-discovery.js +13 -5
- package/dist/lib/recipe-references.js +137 -11
- package/dist/lib/runtime-notifier.d.ts +48 -0
- package/dist/lib/runtime-notifier.js +137 -0
- package/dist/lib/tools.js +18 -9
- package/docs/README.md +1 -1
- package/docs/actor-messages.md +8 -3
- package/docs/async-runs.md +7 -5
- package/docs/recipe-library.md +25 -8
- package/docs/template-recipes.md +37 -7
- package/docs/tool-registry.md +4 -3
- package/index.ts +14 -0
- package/lib/actor-rooms.ts +46 -1
- package/lib/async-runs.ts +433 -49
- package/lib/command-templates.ts +8 -1
- package/lib/observability.ts +133 -20
- package/lib/recipe-discovery.ts +21 -6
- package/lib/recipe-references.ts +141 -17
- package/lib/runtime-notifier.ts +207 -0
- package/lib/tools.ts +36 -13
- package/package.json +2 -3
- package/recipes/music-player.json +1 -1
- package/recipes/pipeline-room-swarm.json +1 -1
- package/scripts/coordinator.mjs +276 -135
- package/scripts/locker.mjs +87 -28
- package/scripts/music-player.mjs +401 -94
- package/scripts/validate-recipe.mjs +2 -2
- package/skills/actors/SKILL.md +10 -9
- package/skills/swarm/SKILL.md +1 -1
- package/index.js +0 -19
package/scripts/coordinator.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
4
|
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import { createConnection } from "node:net";
|
|
5
6
|
|
|
6
7
|
function arg(name, fallback = "") {
|
|
7
8
|
const prefix = `--${name}=`;
|
|
@@ -40,7 +41,8 @@ function parseRoles(raw) {
|
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
async function loadRoles(raw, path) {
|
|
43
|
-
if (path.trim())
|
|
44
|
+
if (path.trim())
|
|
45
|
+
return normalizeRoles(JSON.parse(await readFile(path, "utf8")));
|
|
44
46
|
return parseRoles(raw);
|
|
45
47
|
}
|
|
46
48
|
|
|
@@ -66,7 +68,8 @@ const STATE_LOCK_TIMEOUT_MS = 5000;
|
|
|
66
68
|
async function waitForPath(path, timeoutMs = 5000) {
|
|
67
69
|
const started = Date.now();
|
|
68
70
|
while (!existsSync(path)) {
|
|
69
|
-
if (Date.now() - started > timeoutMs)
|
|
71
|
+
if (Date.now() - started > timeoutMs)
|
|
72
|
+
throw new Error(`timed out waiting for ${path}`);
|
|
70
73
|
await sleep(50);
|
|
71
74
|
}
|
|
72
75
|
}
|
|
@@ -78,7 +81,11 @@ async function acquireStateLock(parentDir, name, label) {
|
|
|
78
81
|
while (true) {
|
|
79
82
|
try {
|
|
80
83
|
await mkdir(lockDir);
|
|
81
|
-
await writeFile(
|
|
84
|
+
await writeFile(
|
|
85
|
+
`${lockDir}/owner.json`,
|
|
86
|
+
`${JSON.stringify({ pid: process.pid, created_at: new Date().toISOString() })}\n`,
|
|
87
|
+
"utf8",
|
|
88
|
+
);
|
|
82
89
|
return async () => rm(lockDir, { recursive: true, force: true });
|
|
83
90
|
} catch (error) {
|
|
84
91
|
try {
|
|
@@ -99,13 +106,42 @@ async function acquireStateLock(parentDir, name, label) {
|
|
|
99
106
|
}
|
|
100
107
|
|
|
101
108
|
async function acquireBranchInboxLock(runId, branchName) {
|
|
102
|
-
return acquireStateLock(
|
|
109
|
+
return acquireStateLock(
|
|
110
|
+
`${runStateDir(runId)}/branches/${branchName}`,
|
|
111
|
+
".inbox.lock",
|
|
112
|
+
`Branch inbox ${branchName}`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function readLockerControl(locker) {
|
|
117
|
+
const controlJson = `${locker.stateDir}/control.json`;
|
|
118
|
+
await waitForPath(controlJson);
|
|
119
|
+
return JSON.parse(await readFile(controlJson, "utf8"));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function writeNamedPipeLine(path, line) {
|
|
123
|
+
return new Promise((resolve, reject) => {
|
|
124
|
+
const socket = createConnection(path);
|
|
125
|
+
let settled = false;
|
|
126
|
+
const finish = (error) => {
|
|
127
|
+
if (settled) return;
|
|
128
|
+
settled = true;
|
|
129
|
+
if (error) reject(error);
|
|
130
|
+
else resolve();
|
|
131
|
+
};
|
|
132
|
+
socket.on("error", finish);
|
|
133
|
+
socket.on("connect", () => socket.end(line, () => finish()));
|
|
134
|
+
});
|
|
103
135
|
}
|
|
104
136
|
|
|
105
137
|
async function writeLockerMessage(locker, message) {
|
|
106
138
|
if (!locker) return;
|
|
107
|
-
await
|
|
108
|
-
|
|
139
|
+
const control = locker.control ?? (await readLockerControl(locker));
|
|
140
|
+
locker.control = control;
|
|
141
|
+
const line = `${JSON.stringify(message)}\n`;
|
|
142
|
+
if (control.type === "named-pipe")
|
|
143
|
+
await writeNamedPipeLine(control.path, line);
|
|
144
|
+
else await writeFile(control.path, line, { flag: "a" });
|
|
109
145
|
await sleep(50);
|
|
110
146
|
}
|
|
111
147
|
|
|
@@ -113,23 +149,31 @@ async function startLocker(config) {
|
|
|
113
149
|
if (!config.locker) return undefined;
|
|
114
150
|
const lockerStateDir = `${runStateDir(config.runId)}/locker`;
|
|
115
151
|
await mkdir(lockerStateDir, { recursive: true });
|
|
116
|
-
const child = spawn(
|
|
117
|
-
"
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
152
|
+
const child = spawn(
|
|
153
|
+
new URL("./locker.mjs", import.meta.url).pathname,
|
|
154
|
+
[
|
|
155
|
+
"serve",
|
|
156
|
+
"--state-dir",
|
|
157
|
+
lockerStateDir,
|
|
158
|
+
"--lease-ms",
|
|
159
|
+
String(config.lockerLeaseMs),
|
|
160
|
+
],
|
|
161
|
+
{
|
|
162
|
+
env: { ...process.env, run_id: config.runId },
|
|
163
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
164
|
+
},
|
|
165
|
+
);
|
|
126
166
|
child.stdout.on("data", (chunk) => process.stdout.write(`[locker] ${chunk}`));
|
|
127
167
|
child.stderr.on("data", (chunk) => process.stderr.write(`[locker] ${chunk}`));
|
|
128
|
-
const locker = { child,
|
|
129
|
-
await
|
|
168
|
+
const locker = { child, stateDir: lockerStateDir };
|
|
169
|
+
locker.control = await readLockerControl(locker);
|
|
130
170
|
await writeLockerMessage(locker, {
|
|
131
171
|
type: "lock.enqueue",
|
|
132
|
-
body: {
|
|
172
|
+
body: {
|
|
173
|
+
id: "coordinator-artifact",
|
|
174
|
+
task: "Own final artifact synthesis",
|
|
175
|
+
resources: [config.artifactPath || "artifact"],
|
|
176
|
+
},
|
|
133
177
|
});
|
|
134
178
|
return locker;
|
|
135
179
|
}
|
|
@@ -147,7 +191,8 @@ async function stopLocker(locker) {
|
|
|
147
191
|
function runPi(prompt, model, thinking) {
|
|
148
192
|
return new Promise((resolve) => {
|
|
149
193
|
const args = [
|
|
150
|
-
"--tools",
|
|
194
|
+
"--tools",
|
|
195
|
+
"inspect,message",
|
|
151
196
|
"--no-context-files",
|
|
152
197
|
"--no-skills",
|
|
153
198
|
"--no-session",
|
|
@@ -163,10 +208,16 @@ function runPi(prompt, model, thinking) {
|
|
|
163
208
|
const child = spawn("pi", args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
164
209
|
let stdout = "";
|
|
165
210
|
let stderr = "";
|
|
166
|
-
child.stdout.on("data", (chunk) => {
|
|
167
|
-
|
|
211
|
+
child.stdout.on("data", (chunk) => {
|
|
212
|
+
stdout += chunk;
|
|
213
|
+
});
|
|
214
|
+
child.stderr.on("data", (chunk) => {
|
|
215
|
+
stderr += chunk;
|
|
216
|
+
});
|
|
168
217
|
child.on("close", (code) => resolve({ code, stdout, stderr }));
|
|
169
|
-
child.on("error", (error) =>
|
|
218
|
+
child.on("error", (error) =>
|
|
219
|
+
resolve({ code: 1, stdout, stderr: String(error) }),
|
|
220
|
+
);
|
|
170
221
|
});
|
|
171
222
|
}
|
|
172
223
|
|
|
@@ -181,9 +232,15 @@ async function readRoomTranscript(config) {
|
|
|
181
232
|
.map((line) => {
|
|
182
233
|
try {
|
|
183
234
|
const message = JSON.parse(line);
|
|
184
|
-
const from = String(message.from || "unknown").replace(
|
|
235
|
+
const from = String(message.from || "unknown").replace(
|
|
236
|
+
/^branch:[^/]+\//,
|
|
237
|
+
"",
|
|
238
|
+
);
|
|
185
239
|
const type = String(message.type || "message");
|
|
186
|
-
const body =
|
|
240
|
+
const body =
|
|
241
|
+
typeof message.body === "string"
|
|
242
|
+
? message.body
|
|
243
|
+
: JSON.stringify(message.body ?? "");
|
|
187
244
|
return `${from} [${type}]: ${body}`;
|
|
188
245
|
} catch {
|
|
189
246
|
return line;
|
|
@@ -206,7 +263,13 @@ async function synthesize(config, locker) {
|
|
|
206
263
|
const transcript = await readRoomTranscript(config);
|
|
207
264
|
const prompt = `Synthesize this transcript into a concise Markdown artifact. Mission: ${config.mission}. Include: Title, Consensus, Roles, Protocol, Final Artifact Shape, Next Actions, Open Questions. Use only the transcript evidence below.\n\nTRANSCRIPT:\n${transcript.slice(-24000)}`;
|
|
208
265
|
const result = await runPi(prompt, config.model, config.thinking);
|
|
209
|
-
await writeFile(
|
|
266
|
+
await writeFile(
|
|
267
|
+
config.artifactPath,
|
|
268
|
+
result.stdout.trim()
|
|
269
|
+
? result.stdout
|
|
270
|
+
: "# Synthesis Artifact\n\nNo synthesis output.\n",
|
|
271
|
+
"utf8",
|
|
272
|
+
);
|
|
210
273
|
if (locker) {
|
|
211
274
|
await writeLockerMessage(locker, {
|
|
212
275
|
type: "lock.complete",
|
|
@@ -223,73 +286,96 @@ async function synthesize(config, locker) {
|
|
|
223
286
|
async function readInboxLines(inboxPath) {
|
|
224
287
|
if (!existsSync(inboxPath)) return [];
|
|
225
288
|
const content = await readFile(inboxPath, "utf8");
|
|
226
|
-
return content
|
|
289
|
+
return content
|
|
290
|
+
.split("\n")
|
|
291
|
+
.filter(Boolean)
|
|
292
|
+
.map((line) => JSON.parse(line));
|
|
227
293
|
}
|
|
228
294
|
|
|
229
295
|
async function writeInboxMessages(inboxPath, messages) {
|
|
230
|
-
await writeFile(
|
|
296
|
+
await writeFile(
|
|
297
|
+
inboxPath,
|
|
298
|
+
messages.map((message) => JSON.stringify(message)).join("\n") + "\n",
|
|
299
|
+
"utf8",
|
|
300
|
+
);
|
|
231
301
|
}
|
|
232
302
|
|
|
233
|
-
async function
|
|
303
|
+
async function mutateBranchInbox(runId, branchName, mutate, fallback) {
|
|
234
304
|
const inboxPath = `${runStateDir(runId)}/branches/${branchName}/inbox.jsonl`;
|
|
235
305
|
const releaseLock = await acquireBranchInboxLock(runId, branchName);
|
|
236
306
|
try {
|
|
237
307
|
const messages = await readInboxLines(inboxPath);
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
if (msg.status !== "queued" && msg.status) return msg;
|
|
242
|
-
const claimed = {
|
|
243
|
-
...msg,
|
|
244
|
-
claimed_at: claimedAt,
|
|
245
|
-
id: msg.id || `legacy-${Date.now()}-${index}`,
|
|
246
|
-
status: "claimed",
|
|
247
|
-
};
|
|
248
|
-
queuedMessages.push(claimed);
|
|
249
|
-
return claimed;
|
|
250
|
-
});
|
|
251
|
-
if (queuedMessages.length > 0) await writeInboxMessages(inboxPath, updated);
|
|
252
|
-
return queuedMessages;
|
|
308
|
+
const result = mutate(messages);
|
|
309
|
+
if (result.messages) await writeInboxMessages(inboxPath, result.messages);
|
|
310
|
+
return result.value;
|
|
253
311
|
} catch {
|
|
254
|
-
return
|
|
312
|
+
return fallback;
|
|
255
313
|
} finally {
|
|
256
314
|
await releaseLock();
|
|
257
315
|
}
|
|
258
316
|
}
|
|
259
317
|
|
|
318
|
+
async function claimQueuedInboxMessages(runId, branchName) {
|
|
319
|
+
return mutateBranchInbox(
|
|
320
|
+
runId,
|
|
321
|
+
branchName,
|
|
322
|
+
(messages) => {
|
|
323
|
+
const claimedAt = new Date().toISOString();
|
|
324
|
+
const queuedMessages = [];
|
|
325
|
+
const updated = messages.map((msg, index) => {
|
|
326
|
+
if (msg.status !== "queued" && msg.status) return msg;
|
|
327
|
+
const claimed = {
|
|
328
|
+
...msg,
|
|
329
|
+
claimed_at: claimedAt,
|
|
330
|
+
id: msg.id || `legacy-${Date.now()}-${index}`,
|
|
331
|
+
status: "claimed",
|
|
332
|
+
};
|
|
333
|
+
queuedMessages.push(claimed);
|
|
334
|
+
return claimed;
|
|
335
|
+
});
|
|
336
|
+
return {
|
|
337
|
+
messages: queuedMessages.length > 0 ? updated : undefined,
|
|
338
|
+
value: queuedMessages,
|
|
339
|
+
};
|
|
340
|
+
},
|
|
341
|
+
[],
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
260
345
|
async function updateInboxMessagesStatus(runId, branchName, ids, status) {
|
|
261
|
-
|
|
262
|
-
const releaseLock = await acquireBranchInboxLock(runId, branchName);
|
|
263
|
-
try {
|
|
264
|
-
const messages = await readInboxLines(inboxPath);
|
|
346
|
+
await mutateBranchInbox(runId, branchName, (messages) => {
|
|
265
347
|
const idSet = new Set(ids);
|
|
266
348
|
const updated = messages.map((msg) => {
|
|
267
349
|
if (!msg.id || !idSet.has(msg.id)) return msg;
|
|
268
350
|
return { ...msg, [`${status}_at`]: new Date().toISOString(), status };
|
|
269
351
|
});
|
|
270
|
-
|
|
271
|
-
}
|
|
272
|
-
// Best-effort write
|
|
273
|
-
} finally {
|
|
274
|
-
await releaseLock();
|
|
275
|
-
}
|
|
352
|
+
return { messages: updated, value: undefined };
|
|
353
|
+
});
|
|
276
354
|
}
|
|
277
355
|
|
|
278
356
|
async function executeParticipantPrompt(role, basePrompt, config) {
|
|
279
357
|
const branchName = role.name;
|
|
280
|
-
const queuedMessages = await claimQueuedInboxMessages(
|
|
358
|
+
const queuedMessages = await claimQueuedInboxMessages(
|
|
359
|
+
config.runId,
|
|
360
|
+
branchName,
|
|
361
|
+
);
|
|
281
362
|
|
|
282
363
|
let finalPrompt = basePrompt;
|
|
283
364
|
const claimedIds = [];
|
|
284
365
|
|
|
285
366
|
if (queuedMessages.length > 0) {
|
|
286
|
-
let inboxSection =
|
|
367
|
+
let inboxSection =
|
|
368
|
+
"\n\nDIRECT INBOX MESSAGES FOR YOU (queued mailbox work):\n";
|
|
287
369
|
for (const msg of queuedMessages) {
|
|
288
|
-
const bodyText =
|
|
370
|
+
const bodyText =
|
|
371
|
+
typeof msg.body === "string"
|
|
372
|
+
? msg.body
|
|
373
|
+
: JSON.stringify(msg.body ?? "");
|
|
289
374
|
inboxSection += `- From: ${msg.from || "unknown"} (Type: ${msg.type || "message"})\n Body: ${bodyText}\n`;
|
|
290
375
|
if (msg.id) claimedIds.push(msg.id);
|
|
291
376
|
}
|
|
292
|
-
inboxSection +=
|
|
377
|
+
inboxSection +=
|
|
378
|
+
"\nPlease acknowledge and address these direct messages in your response.\n";
|
|
293
379
|
finalPrompt += inboxSection;
|
|
294
380
|
}
|
|
295
381
|
|
|
@@ -297,7 +383,12 @@ async function executeParticipantPrompt(role, basePrompt, config) {
|
|
|
297
383
|
|
|
298
384
|
if (claimedIds.length > 0) {
|
|
299
385
|
const finalStatus = result.code === 0 ? "handled" : "failed";
|
|
300
|
-
await updateInboxMessagesStatus(
|
|
386
|
+
await updateInboxMessagesStatus(
|
|
387
|
+
config.runId,
|
|
388
|
+
branchName,
|
|
389
|
+
claimedIds,
|
|
390
|
+
finalStatus,
|
|
391
|
+
);
|
|
301
392
|
}
|
|
302
393
|
|
|
303
394
|
return result;
|
|
@@ -308,8 +399,11 @@ async function participantRound(role, round, config) {
|
|
|
308
399
|
const address = `branch:${config.runId}/${role.name}`;
|
|
309
400
|
const prompt = `You are ${displayName} (${address}), ${role.persona}. Mission: ${config.mission}. Round ${round}/${config.rounds}. First call inspect target=${config.room} view=previews lines=30 and inspect target=${config.room} view=contacts. Then call message once to ${config.room} from ${address} type=chat.message. Body: 2-4 sentences that react to a named participant, propose the next coordination step, and refine the shared artifact. Use contacts for peer names and addresses. End stdout with summary <=160 chars.`;
|
|
310
401
|
const result = await executeParticipantPrompt(role, prompt, config);
|
|
311
|
-
process.stdout.write(
|
|
312
|
-
|
|
402
|
+
process.stdout.write(
|
|
403
|
+
`[${role.name} round ${round}] code=${result.code}\n${result.stdout}\n`,
|
|
404
|
+
);
|
|
405
|
+
if (result.stderr.trim())
|
|
406
|
+
process.stderr.write(`[${role.name} round ${round}] ${result.stderr}\n`);
|
|
313
407
|
}
|
|
314
408
|
|
|
315
409
|
async function participantJoin(role, config) {
|
|
@@ -330,8 +424,11 @@ async function participantLeave(role, config) {
|
|
|
330
424
|
async function runConsensus(config, locker) {
|
|
331
425
|
await Promise.all(config.roles.map((role) => participantJoin(role, config)));
|
|
332
426
|
for (let round = 1; round <= config.rounds; round += 1) {
|
|
333
|
-
await Promise.all(
|
|
334
|
-
|
|
427
|
+
await Promise.all(
|
|
428
|
+
config.roles.map((role) => participantRound(role, round, config)),
|
|
429
|
+
);
|
|
430
|
+
if (round < config.rounds && config.delay > 0)
|
|
431
|
+
await sleep(config.delay * 1000);
|
|
335
432
|
}
|
|
336
433
|
await Promise.all(config.roles.map((role) => participantLeave(role, config)));
|
|
337
434
|
}
|
|
@@ -342,7 +439,8 @@ async function runPipeline(config, locker) {
|
|
|
342
439
|
await participantJoin(role, config);
|
|
343
440
|
for (let round = 1; round <= config.rounds; round += 1) {
|
|
344
441
|
await participantRound(role, round, config);
|
|
345
|
-
if (round < config.rounds && config.delay > 0)
|
|
442
|
+
if (round < config.rounds && config.delay > 0)
|
|
443
|
+
await sleep(config.delay * 1000);
|
|
346
444
|
}
|
|
347
445
|
await participantLeave(role, config);
|
|
348
446
|
}
|
|
@@ -350,14 +448,17 @@ async function runPipeline(config, locker) {
|
|
|
350
448
|
|
|
351
449
|
// 3. fanout mode: run completely parallel independent processes
|
|
352
450
|
async function runFanout(config, locker) {
|
|
353
|
-
await Promise.all(
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
451
|
+
await Promise.all(
|
|
452
|
+
config.roles.map(async (role) => {
|
|
453
|
+
await participantJoin(role, config);
|
|
454
|
+
for (let round = 1; round <= config.rounds; round += 1) {
|
|
455
|
+
await participantRound(role, round, config);
|
|
456
|
+
if (round < config.rounds && config.delay > 0)
|
|
457
|
+
await sleep(config.delay * 1000);
|
|
458
|
+
}
|
|
459
|
+
await participantLeave(role, config);
|
|
460
|
+
}),
|
|
461
|
+
);
|
|
361
462
|
}
|
|
362
463
|
|
|
363
464
|
// 4. pool mode: dynamic task pulling from the locker queue
|
|
@@ -369,58 +470,75 @@ async function runPool(config, locker) {
|
|
|
369
470
|
for (let index = 1; index <= 3; index += 1) {
|
|
370
471
|
await writeLockerMessage(locker, {
|
|
371
472
|
type: "lock.enqueue",
|
|
372
|
-
body: {
|
|
473
|
+
body: {
|
|
474
|
+
id: `subtask-${index}`,
|
|
475
|
+
task: `Execute subtask ${index} under mission: ${config.mission}`,
|
|
476
|
+
resources: [`resource-${index}`],
|
|
477
|
+
},
|
|
373
478
|
});
|
|
374
479
|
}
|
|
375
480
|
|
|
376
481
|
// Workers concurrently poll task queue
|
|
377
|
-
await Promise.all(
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
const result = await executeParticipantPrompt(role, prompt, config);
|
|
401
|
-
|
|
402
|
-
process.stdout.write(`[${role.name} completed ${taskId}] code=${result.code}\n${result.stdout}\n`);
|
|
482
|
+
await Promise.all(
|
|
483
|
+
config.roles.map(async (role) => {
|
|
484
|
+
const address = `branch:${config.runId}/${role.name}`;
|
|
485
|
+
await participantJoin(role, config);
|
|
486
|
+
|
|
487
|
+
while (true) {
|
|
488
|
+
// Dequeue/Claim a task from the locker
|
|
489
|
+
await writeLockerMessage(locker, {
|
|
490
|
+
type: "lock.claim",
|
|
491
|
+
body: { owner: role.name },
|
|
492
|
+
});
|
|
493
|
+
await sleep(100);
|
|
494
|
+
|
|
495
|
+
// Check locks and queue state from locker snapshot
|
|
496
|
+
const lockerData = await readJsonFile(`${locker.stateDir}/locks.json`);
|
|
497
|
+
const assignedTask = Object.values(lockerData).find(
|
|
498
|
+
(lock) => lock.owner === role.name,
|
|
499
|
+
);
|
|
500
|
+
|
|
501
|
+
if (!assignedTask) {
|
|
502
|
+
// No task assigned or queue is empty
|
|
503
|
+
break;
|
|
504
|
+
}
|
|
403
505
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
}
|
|
506
|
+
const taskId = assignedTask.task;
|
|
507
|
+
const prompt = `You are ${role.name}, ${role.persona}. You have been assigned task ${taskId}: "${assignedTask.task}". Solve it as part of the overall mission: ${config.mission}. End your response with a clear summary.`;
|
|
508
|
+
const result = await executeParticipantPrompt(role, prompt, config);
|
|
509
|
+
|
|
510
|
+
process.stdout.write(
|
|
511
|
+
`[${role.name} completed ${taskId}] code=${result.code}\n${result.stdout}\n`,
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
// Report task completion back to locker and release resource locks
|
|
515
|
+
await writeLockerMessage(locker, {
|
|
516
|
+
type: "lock.complete",
|
|
517
|
+
body: { id: taskId, result: result.stdout },
|
|
518
|
+
});
|
|
519
|
+
// Release any locks associated with this resource
|
|
520
|
+
for (const [res, lock] of Object.entries(lockerData)) {
|
|
521
|
+
if (lock.owner === role.name) {
|
|
522
|
+
await writeLockerMessage(locker, {
|
|
523
|
+
type: "lock.release",
|
|
524
|
+
body: { resource: res },
|
|
525
|
+
});
|
|
526
|
+
}
|
|
416
527
|
}
|
|
417
528
|
}
|
|
418
|
-
}
|
|
419
529
|
|
|
420
|
-
|
|
421
|
-
|
|
530
|
+
await participantLeave(role, config);
|
|
531
|
+
}),
|
|
532
|
+
);
|
|
422
533
|
}
|
|
423
534
|
|
|
535
|
+
const coordinatorModes = {
|
|
536
|
+
consensus: runConsensus,
|
|
537
|
+
fanout: runFanout,
|
|
538
|
+
pipeline: runPipeline,
|
|
539
|
+
pool: runPool,
|
|
540
|
+
};
|
|
541
|
+
|
|
424
542
|
async function readJsonFile(path) {
|
|
425
543
|
try {
|
|
426
544
|
return JSON.parse(await readFile(path, "utf8"));
|
|
@@ -430,20 +548,50 @@ async function readJsonFile(path) {
|
|
|
430
548
|
}
|
|
431
549
|
|
|
432
550
|
const defaultRoles = [
|
|
433
|
-
{
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
{
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
551
|
+
{
|
|
552
|
+
name: "mapper",
|
|
553
|
+
persona: "systems mapper; tracks shared structure and dependencies",
|
|
554
|
+
},
|
|
555
|
+
{
|
|
556
|
+
name: "memory",
|
|
557
|
+
persona: "memory keeper; preserves decisions and unresolved questions",
|
|
558
|
+
},
|
|
559
|
+
{
|
|
560
|
+
name: "risk",
|
|
561
|
+
persona: "risk scout; challenges weak coordination and missing owners",
|
|
562
|
+
},
|
|
563
|
+
{
|
|
564
|
+
name: "flow",
|
|
565
|
+
persona: "flow designer; turns scattered ideas into process rhythm",
|
|
566
|
+
},
|
|
567
|
+
{
|
|
568
|
+
name: "operator",
|
|
569
|
+
persona: "operator; converts ideas into concrete next actions",
|
|
570
|
+
},
|
|
571
|
+
{
|
|
572
|
+
name: "narrative",
|
|
573
|
+
persona: "narrative synthesizer; keeps the artifact coherent",
|
|
574
|
+
},
|
|
575
|
+
{
|
|
576
|
+
name: "interface",
|
|
577
|
+
persona: "interface designer; makes outputs visible and usable",
|
|
578
|
+
},
|
|
579
|
+
{
|
|
580
|
+
name: "facilitator",
|
|
581
|
+
persona: "facilitator; asks for consensus and convergence",
|
|
582
|
+
},
|
|
441
583
|
];
|
|
442
584
|
|
|
443
585
|
const config = {
|
|
444
|
-
runId: arg(
|
|
586
|
+
runId: arg(
|
|
587
|
+
"run-id",
|
|
588
|
+
process.env.run_id || process.env.RUN_ID || "coordinator-run",
|
|
589
|
+
),
|
|
445
590
|
mode: arg("mode", "consensus"), // "consensus" (default/swarm), "pipeline", "fanout", "pool"
|
|
446
|
-
mission: arg(
|
|
591
|
+
mission: arg(
|
|
592
|
+
"mission",
|
|
593
|
+
"Coordinate a shared artifact and converge on next actions",
|
|
594
|
+
),
|
|
447
595
|
model: arg("model", ""),
|
|
448
596
|
thinking: arg("thinking", "off"),
|
|
449
597
|
roles: await loadRoles(arg("roles", ""), arg("roles-path", "")),
|
|
@@ -464,16 +612,9 @@ const failures = [];
|
|
|
464
612
|
const locker = await startLocker(config);
|
|
465
613
|
|
|
466
614
|
try {
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
await runFanout(config, locker);
|
|
471
|
-
} else if (config.mode === "pool") {
|
|
472
|
-
await runPool(config, locker);
|
|
473
|
-
} else {
|
|
474
|
-
// default: consensus / swarm
|
|
475
|
-
await runConsensus(config, locker);
|
|
476
|
-
}
|
|
615
|
+
const runMode = coordinatorModes[config.mode];
|
|
616
|
+
if (!runMode) throw new Error(`Unknown coordinator mode: ${config.mode}`);
|
|
617
|
+
await runMode(config, locker);
|
|
477
618
|
await synthesize(config, locker);
|
|
478
619
|
} catch (globalError) {
|
|
479
620
|
failures.push(`Global coordinator error: ${globalError.message}`);
|