@llblab/pi-actors 0.17.1 → 0.19.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.
Files changed (88) hide show
  1. package/AGENTS.md +6 -2
  2. package/BACKLOG.md +32 -26
  3. package/CHANGELOG.md +19 -3
  4. package/README.md +23 -8
  5. package/docs/actor-messages.md +5 -3
  6. package/docs/async-runs.md +3 -5
  7. package/docs/command-templates.md +2 -0
  8. package/docs/recipe-library.md +3 -1
  9. package/docs/task-first-recipes.md +29 -0
  10. package/docs/template-recipes.md +9 -14
  11. package/index.ts +111 -32
  12. package/lib/actor-inspector-tui.ts +192 -42
  13. package/lib/actor-rooms.ts +220 -26
  14. package/lib/async-runs.ts +59 -1
  15. package/lib/execution.ts +17 -0
  16. package/lib/file-state.ts +2 -1
  17. package/lib/observability.ts +82 -2
  18. package/lib/prompts.ts +2 -2
  19. package/lib/recipe-discovery.ts +86 -6
  20. package/lib/recipe-migration.ts +0 -2
  21. package/lib/recipe-references.ts +43 -10
  22. package/lib/temp.ts +55 -2
  23. package/lib/tools.ts +99 -11
  24. package/package.json +1 -1
  25. package/recipes/coordinator-locker.json +1 -2
  26. package/recipes/lens-swarm.json +0 -1
  27. package/recipes/locker.json +45 -0
  28. package/recipes/music-player.json +0 -1
  29. package/recipes/pipeline-architect-coordinator.json +0 -1
  30. package/recipes/pipeline-artifact-bundle.json +0 -1
  31. package/recipes/pipeline-artifact-report.json +0 -1
  32. package/recipes/pipeline-artifact-write.json +0 -1
  33. package/recipes/pipeline-async-run-ops.json +0 -1
  34. package/recipes/pipeline-checkpoint-continuation.json +0 -1
  35. package/recipes/pipeline-development-tasking.json +0 -1
  36. package/recipes/pipeline-docs-maintenance.json +0 -1
  37. package/recipes/pipeline-media-library.json +0 -1
  38. package/recipes/pipeline-quorum-review.json +0 -1
  39. package/recipes/pipeline-release-readiness.json +0 -1
  40. package/recipes/pipeline-release-summary.json +0 -1
  41. package/recipes/pipeline-repo-health.json +0 -1
  42. package/recipes/pipeline-research-synthesis.json +0 -1
  43. package/recipes/pipeline-review-readiness.json +0 -1
  44. package/recipes/pipeline-room-swarm.json +3 -2
  45. package/recipes/subagent-artifact.json +0 -1
  46. package/recipes/subagent-checkpoint.json +0 -1
  47. package/recipes/subagent-conflict-report.json +0 -1
  48. package/recipes/subagent-contradiction-map.json +0 -1
  49. package/recipes/subagent-critic.json +0 -1
  50. package/recipes/subagent-evidence-map.json +0 -1
  51. package/recipes/subagent-followup.json +0 -1
  52. package/recipes/subagent-judge.json +0 -1
  53. package/recipes/subagent-merge.json +0 -1
  54. package/recipes/subagent-message.json +0 -1
  55. package/recipes/subagent-normalize.json +0 -1
  56. package/recipes/subagent-plan.json +0 -1
  57. package/recipes/subagent-prompt.json +0 -1
  58. package/recipes/subagent-quorum.json +0 -1
  59. package/recipes/subagent-review-coordinator.json +0 -1
  60. package/recipes/subagent-review.json +0 -1
  61. package/recipes/subagent-task-card.json +0 -1
  62. package/recipes/subagent-tools.json +0 -1
  63. package/recipes/subagent-verify.json +0 -1
  64. package/recipes/subagents-prompts.json +0 -1
  65. package/recipes/utility-actor-message.json +0 -1
  66. package/recipes/utility-artifact-manifest.json +0 -1
  67. package/recipes/utility-artifact-write.json +0 -1
  68. package/recipes/utility-changelog-head.json +0 -1
  69. package/recipes/utility-changelog-section.json +0 -1
  70. package/recipes/utility-coordinator-lock-snapshot.json +0 -1
  71. package/recipes/utility-git-log.json +0 -1
  72. package/recipes/utility-git-status.json +0 -1
  73. package/recipes/utility-jsonl-tail.json +0 -1
  74. package/recipes/utility-markdown-index.json +0 -1
  75. package/recipes/utility-package-summary.json +0 -1
  76. package/recipes/utility-playlist-build.json +0 -1
  77. package/recipes/utility-playlist-scan.json +0 -1
  78. package/recipes/utility-run-ops-snapshot.json +0 -1
  79. package/recipes/utility-run-state-files.json +0 -1
  80. package/recipes/utility-run-summary.json +0 -1
  81. package/recipes/utility-skill-summary.json +0 -1
  82. package/recipes/utility-validate-recipe.json +0 -1
  83. package/recipes/utility-validation-wrapper.json +0 -1
  84. package/scripts/coordinator.mjs +434 -0
  85. package/scripts/{coordinator-locker.mjs → locker.mjs} +23 -22
  86. package/skills/actors/SKILL.md +26 -12
  87. package/skills/swarm/SKILL.md +15 -1
  88. package/scripts/room-swarm.mjs +0 -244
@@ -0,0 +1,434 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { existsSync } from "node:fs";
4
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
5
+
6
+ function arg(name, fallback = "") {
7
+ const prefix = `--${name}=`;
8
+ const value = process.argv.find((item) => item.startsWith(prefix));
9
+ return value ? value.slice(prefix.length) : fallback;
10
+ }
11
+
12
+ function numberArg(name, fallback) {
13
+ const value = Number(arg(name, String(fallback)));
14
+ return Number.isFinite(value) && value >= 0 ? value : fallback;
15
+ }
16
+
17
+ function boolArg(name, fallback = false) {
18
+ const value = arg(name, String(fallback)).trim().toLowerCase();
19
+ return ["1", "true", "yes", "on"].includes(value);
20
+ }
21
+
22
+ function normalizeRoles(value) {
23
+ if (!Array.isArray(value)) throw new Error("roles must be an array");
24
+ return value.map((role, index) => ({
25
+ name: String(role.name ?? `actor-${index + 1}`),
26
+ persona: String(role.persona ?? role.role ?? "room participant"),
27
+ }));
28
+ }
29
+
30
+ function parseRoles(raw) {
31
+ if (!raw.trim()) return defaultRoles;
32
+ try {
33
+ return normalizeRoles(JSON.parse(raw));
34
+ } catch {
35
+ return raw.split(",").map((item, index) => ({
36
+ name: item.trim() || `actor-${index + 1}`,
37
+ persona: "room participant",
38
+ }));
39
+ }
40
+ }
41
+
42
+ async function loadRoles(raw, path) {
43
+ if (path.trim()) return normalizeRoles(JSON.parse(await readFile(path, "utf8")));
44
+ return parseRoles(raw);
45
+ }
46
+
47
+ function shellQuote(value) {
48
+ return `'${String(value).replaceAll("'", "'\\''")}'`;
49
+ }
50
+
51
+ function agentDir() {
52
+ return process.env.PI_CODING_AGENT_DIR || `${process.env.HOME}/.pi/agent`;
53
+ }
54
+
55
+ function runStateDir(runId) {
56
+ return `${agentDir()}/tmp/pi-actors/runs/${runId}`;
57
+ }
58
+
59
+ async function sleep(ms) {
60
+ await new Promise((resolve) => setTimeout(resolve, ms));
61
+ }
62
+
63
+ async function waitForPath(path, timeoutMs = 5000) {
64
+ const started = Date.now();
65
+ while (!existsSync(path)) {
66
+ if (Date.now() - started > timeoutMs) throw new Error(`timed out waiting for ${path}`);
67
+ await sleep(50);
68
+ }
69
+ }
70
+
71
+ async function writeLockerMessage(locker, message) {
72
+ if (!locker) return;
73
+ await waitForPath(locker.controlPath);
74
+ await writeFile(locker.controlPath, `${JSON.stringify(message)}\n`, { flag: "a" });
75
+ await sleep(50);
76
+ }
77
+
78
+ async function startLocker(config) {
79
+ if (!config.locker) return undefined;
80
+ const lockerStateDir = `${runStateDir(config.runId)}/locker`;
81
+ await mkdir(lockerStateDir, { recursive: true });
82
+ const child = spawn(new URL("./locker.mjs", import.meta.url).pathname, [
83
+ "serve",
84
+ "--state-dir",
85
+ lockerStateDir,
86
+ "--lease-ms",
87
+ String(config.lockerLeaseMs),
88
+ ], {
89
+ env: { ...process.env, run_id: config.runId },
90
+ stdio: ["ignore", "pipe", "pipe"],
91
+ });
92
+ child.stdout.on("data", (chunk) => process.stdout.write(`[locker] ${chunk}`));
93
+ child.stderr.on("data", (chunk) => process.stderr.write(`[locker] ${chunk}`));
94
+ const locker = { child, controlPath: `${lockerStateDir}/control.fifo`, stateDir: lockerStateDir };
95
+ await waitForPath(locker.controlPath);
96
+ await writeLockerMessage(locker, {
97
+ type: "lock.enqueue",
98
+ body: { id: "coordinator-artifact", task: "Own final artifact synthesis", resources: [config.artifactPath || "artifact"] },
99
+ });
100
+ return locker;
101
+ }
102
+
103
+ async function stopLocker(locker) {
104
+ if (!locker) return;
105
+ try {
106
+ await writeLockerMessage(locker, { type: "control.stop", body: {} });
107
+ await sleep(100);
108
+ } finally {
109
+ if (!locker.child.killed) locker.child.kill("SIGTERM");
110
+ }
111
+ }
112
+
113
+ function runPi(prompt, model, thinking) {
114
+ return new Promise((resolve) => {
115
+ const child = spawn("pi", [
116
+ "--model", model,
117
+ "--thinking", thinking,
118
+ "--tools", "inspect,message",
119
+ "--no-context-files",
120
+ "--no-skills",
121
+ "--no-session",
122
+ "-p",
123
+ prompt,
124
+ ], { stdio: ["ignore", "pipe", "pipe"] });
125
+ let stdout = "";
126
+ let stderr = "";
127
+ child.stdout.on("data", (chunk) => { stdout += chunk; });
128
+ child.stderr.on("data", (chunk) => { stderr += chunk; });
129
+ child.on("close", (code) => resolve({ code, stdout, stderr }));
130
+ child.on("error", (error) => resolve({ code: 1, stdout, stderr: String(error) }));
131
+ });
132
+ }
133
+
134
+ async function readRoomTranscript(config) {
135
+ const messagesPath = `${runStateDir(config.runId)}/rooms/main/messages.jsonl`;
136
+ try {
137
+ const raw = await readFile(messagesPath, "utf8");
138
+ return raw
139
+ .split("\n")
140
+ .filter(Boolean)
141
+ .slice(-160)
142
+ .map((line) => {
143
+ try {
144
+ const message = JSON.parse(line);
145
+ const from = String(message.from || "unknown").replace(/^branch:[^/]+\//, "");
146
+ const type = String(message.type || "message");
147
+ const body = typeof message.body === "string" ? message.body : JSON.stringify(message.body ?? "");
148
+ return `${from} [${type}]: ${body}`;
149
+ } catch {
150
+ return line;
151
+ }
152
+ })
153
+ .join("\n");
154
+ } catch {
155
+ return "";
156
+ }
157
+ }
158
+
159
+ async function synthesize(config, locker) {
160
+ if (!config.artifactPath) return;
161
+ if (locker) {
162
+ await writeLockerMessage(locker, {
163
+ type: "lock.claim",
164
+ body: { owner: "coordinator:synthesizer" },
165
+ });
166
+ }
167
+ const transcript = await readRoomTranscript(config);
168
+ 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)}`;
169
+ const result = await runPi(prompt, config.model, config.thinking);
170
+ await writeFile(config.artifactPath, result.stdout.trim() ? result.stdout : "# Synthesis Artifact\n\nNo synthesis output.\n", "utf8");
171
+ if (locker) {
172
+ await writeLockerMessage(locker, {
173
+ type: "lock.complete",
174
+ body: { id: "coordinator-artifact", artifact: config.artifactPath },
175
+ });
176
+ await writeLockerMessage(locker, {
177
+ type: "lock.release",
178
+ body: { resource: config.artifactPath },
179
+ });
180
+ }
181
+ process.stdout.write(`artifact=${config.artifactPath}\n`);
182
+ }
183
+
184
+ async function updateInboxMessagesStatus(runId, branchName, ids, status) {
185
+ const inboxPath = `${runStateDir(runId)}/branches/${branchName}/inbox.jsonl`;
186
+ try {
187
+ if (!existsSync(inboxPath)) return;
188
+ const content = await readFile(inboxPath, "utf8");
189
+ const lines = content.split("\n").filter(Boolean);
190
+ const updatedLines = [];
191
+ for (const line of lines) {
192
+ const msg = JSON.parse(line);
193
+ if (msg.id && ids.includes(msg.id)) {
194
+ msg.status = status;
195
+ msg[`${status}_at`] = new Date().toISOString();
196
+ }
197
+ updatedLines.push(JSON.stringify(msg));
198
+ }
199
+ await writeFile(inboxPath, updatedLines.join("\n") + "\n", "utf8");
200
+ } catch (err) {
201
+ // Best-effort write
202
+ }
203
+ }
204
+
205
+ async function executeParticipantPrompt(role, basePrompt, config) {
206
+ const branchName = role.name;
207
+ const inboxPath = `${runStateDir(config.runId)}/branches/${branchName}/inbox.jsonl`;
208
+ const queuedMessages = [];
209
+
210
+ try {
211
+ if (existsSync(inboxPath)) {
212
+ const content = await readFile(inboxPath, "utf8");
213
+ const lines = content.split("\n").filter(Boolean);
214
+ for (const line of lines) {
215
+ const msg = JSON.parse(line);
216
+ if (msg.status === "queued" || !msg.status) {
217
+ queuedMessages.push(msg);
218
+ }
219
+ }
220
+ }
221
+ } catch (err) {
222
+ // Best-effort read
223
+ }
224
+
225
+ let finalPrompt = basePrompt;
226
+ const claimedIds = [];
227
+
228
+ if (queuedMessages.length > 0) {
229
+ let inboxSection = "\n\nDIRECT INBOX MESSAGES FOR YOU (FIFO queue):\n";
230
+ for (const msg of queuedMessages) {
231
+ const bodyText = typeof msg.body === "string" ? msg.body : JSON.stringify(msg.body ?? "");
232
+ inboxSection += `- From: ${msg.from || "unknown"} (Type: ${msg.type || "message"})\n Body: ${bodyText}\n`;
233
+ if (msg.id) claimedIds.push(msg.id);
234
+ }
235
+ inboxSection += "\nPlease acknowledge and address these direct messages in your response.\n";
236
+ finalPrompt += inboxSection;
237
+
238
+ if (claimedIds.length > 0) {
239
+ await updateInboxMessagesStatus(config.runId, branchName, claimedIds, "claimed");
240
+ }
241
+ }
242
+
243
+ const result = await runPi(finalPrompt, config.model, config.thinking);
244
+
245
+ if (claimedIds.length > 0) {
246
+ const finalStatus = result.code === 0 ? "handled" : "failed";
247
+ await updateInboxMessagesStatus(config.runId, branchName, claimedIds, finalStatus);
248
+ }
249
+
250
+ return result;
251
+ }
252
+
253
+ async function participantRound(role, round, config) {
254
+ const displayName = role.name;
255
+ const address = `branch:${config.runId}/${role.name}`;
256
+ 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.`;
257
+ const result = await executeParticipantPrompt(role, prompt, config);
258
+ process.stdout.write(`[${role.name} round ${round}] code=${result.code}\n${result.stdout}\n`);
259
+ if (result.stderr.trim()) process.stderr.write(`[${role.name} round ${round}] ${result.stderr}\n`);
260
+ }
261
+
262
+ async function participantJoin(role, config) {
263
+ const displayName = role.name;
264
+ const address = `branch:${config.runId}/${role.name}`;
265
+ const joinPrompt = `You are ${displayName}, ${role.persona}. Mission: ${config.mission}. Call tool message exactly once with to=${shellQuote(config.room)}, from=${shellQuote(address)}, type='actor.join', summary='${displayName} joined', body JSON {"role":${JSON.stringify(role.persona)},"display":${JSON.stringify(displayName)},"caps":["coordination","synthesis"],"claim":"coordinate on mission"}. Then print one short line.`;
266
+ await runPi(joinPrompt, config.model, config.thinking);
267
+ }
268
+
269
+ async function participantLeave(role, config) {
270
+ const displayName = role.name;
271
+ const address = `branch:${config.runId}/${role.name}`;
272
+ const leavePrompt = `Call tool message exactly once with to=${shellQuote(config.room)}, from=${shellQuote(address)}, type='actor.leave', summary='${displayName} left', body='finished coordinated work'. Then print goodbye.`;
273
+ await runPi(leavePrompt, config.model, config.thinking);
274
+ }
275
+
276
+ // 1. consensus / swarm mode: iterative chat in a room
277
+ async function runConsensus(config, locker) {
278
+ await Promise.all(config.roles.map((role) => participantJoin(role, config)));
279
+ for (let round = 1; round <= config.rounds; round += 1) {
280
+ await Promise.all(config.roles.map((role) => participantRound(role, round, config)));
281
+ if (round < config.rounds && config.delay > 0) await sleep(config.delay * 1000);
282
+ }
283
+ await Promise.all(config.roles.map((role) => participantLeave(role, config)));
284
+ }
285
+
286
+ // 2. pipeline mode: sequential step-by-step
287
+ async function runPipeline(config, locker) {
288
+ for (const role of config.roles) {
289
+ await participantJoin(role, config);
290
+ for (let round = 1; round <= config.rounds; round += 1) {
291
+ await participantRound(role, round, config);
292
+ if (round < config.rounds && config.delay > 0) await sleep(config.delay * 1000);
293
+ }
294
+ await participantLeave(role, config);
295
+ }
296
+ }
297
+
298
+ // 3. fanout mode: run completely parallel independent processes
299
+ async function runFanout(config, locker) {
300
+ await Promise.all(config.roles.map(async (role) => {
301
+ await participantJoin(role, config);
302
+ for (let round = 1; round <= config.rounds; round += 1) {
303
+ await participantRound(role, round, config);
304
+ if (round < config.rounds && config.delay > 0) await sleep(config.delay * 1000);
305
+ }
306
+ await participantLeave(role, config);
307
+ }));
308
+ }
309
+
310
+ // 4. pool mode: dynamic task pulling from the locker queue
311
+ async function runPool(config, locker) {
312
+ if (!locker) {
313
+ throw new Error("Pool mode requires locker enabled (--locker=true)");
314
+ }
315
+ // Enqueue a set of sub-tasks based on the mission splits (for demo/simulation, we enqueue 3 sub-tasks)
316
+ for (let index = 1; index <= 3; index += 1) {
317
+ await writeLockerMessage(locker, {
318
+ type: "lock.enqueue",
319
+ body: { id: `subtask-${index}`, task: `Execute subtask ${index} under mission: ${config.mission}`, resources: [`resource-${index}`] },
320
+ });
321
+ }
322
+
323
+ // Workers concurrently poll task queue
324
+ await Promise.all(config.roles.map(async (role) => {
325
+ const address = `branch:${config.runId}/${role.name}`;
326
+ await participantJoin(role, config);
327
+
328
+ while (true) {
329
+ // Dequeue/Claim a task from the locker
330
+ await writeLockerMessage(locker, {
331
+ type: "lock.claim",
332
+ body: { owner: role.name },
333
+ });
334
+ await sleep(100);
335
+
336
+ // Check locks and queue state from locker snapshot
337
+ const lockerData = await readJsonFile(`${locker.stateDir}/locks.json`);
338
+ const assignedTask = Object.values(lockerData).find(lock => lock.owner === role.name);
339
+
340
+ if (!assignedTask) {
341
+ // No task assigned or queue is empty
342
+ break;
343
+ }
344
+
345
+ const taskId = assignedTask.task;
346
+ 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.`;
347
+ const result = await executeParticipantPrompt(role, prompt, config);
348
+
349
+ process.stdout.write(`[${role.name} completed ${taskId}] code=${result.code}\n${result.stdout}\n`);
350
+
351
+ // Report task completion back to locker and release resource locks
352
+ await writeLockerMessage(locker, {
353
+ type: "lock.complete",
354
+ body: { id: taskId, result: result.stdout },
355
+ });
356
+ // Release any locks associated with this resource
357
+ for (const [res, lock] of Object.entries(lockerData)) {
358
+ if (lock.owner === role.name) {
359
+ await writeLockerMessage(locker, {
360
+ type: "lock.release",
361
+ body: { resource: res },
362
+ });
363
+ }
364
+ }
365
+ }
366
+
367
+ await participantLeave(role, config);
368
+ }));
369
+ }
370
+
371
+ async function readJsonFile(path) {
372
+ try {
373
+ return JSON.parse(await readFile(path, "utf8"));
374
+ } catch {
375
+ return {};
376
+ }
377
+ }
378
+
379
+ const defaultRoles = [
380
+ { name: "mapper", persona: "systems mapper; tracks shared structure and dependencies" },
381
+ { name: "memory", persona: "memory keeper; preserves decisions and unresolved questions" },
382
+ { name: "risk", persona: "risk scout; challenges weak coordination and missing owners" },
383
+ { name: "flow", persona: "flow designer; turns scattered ideas into process rhythm" },
384
+ { name: "operator", persona: "operator; converts ideas into concrete next actions" },
385
+ { name: "narrative", persona: "narrative synthesizer; keeps the artifact coherent" },
386
+ { name: "interface", persona: "interface designer; makes outputs visible and usable" },
387
+ { name: "facilitator", persona: "facilitator; asks for consensus and convergence" },
388
+ ];
389
+
390
+ const config = {
391
+ runId: arg("run-id", process.env.run_id || process.env.RUN_ID || "coordinator-run"),
392
+ mode: arg("mode", "consensus"), // "consensus" (default/swarm), "pipeline", "fanout", "pool"
393
+ mission: arg("mission", "Coordinate a shared artifact and converge on next actions"),
394
+ model: arg("model", ""),
395
+ thinking: arg("thinking", "off"),
396
+ roles: await loadRoles(arg("roles", ""), arg("roles-path", "")),
397
+ rounds: numberArg("rounds", 4),
398
+ delay: numberArg("delay", 10),
399
+ artifactPath: arg("artifact-path", ""),
400
+ locker: boolArg("locker", false),
401
+ lockerLeaseMs: numberArg("locker-lease-ms", 600000),
402
+ };
403
+
404
+ if (!config.model) {
405
+ console.error("--model is required");
406
+ process.exit(2);
407
+ }
408
+ config.room = `room:${config.runId}`;
409
+
410
+ const failures = [];
411
+ const locker = await startLocker(config);
412
+
413
+ try {
414
+ if (config.mode === "pipeline") {
415
+ await runPipeline(config, locker);
416
+ } else if (config.mode === "fanout") {
417
+ await runFanout(config, locker);
418
+ } else if (config.mode === "pool") {
419
+ await runPool(config, locker);
420
+ } else {
421
+ // default: consensus / swarm
422
+ await runConsensus(config, locker);
423
+ }
424
+ await synthesize(config, locker);
425
+ } catch (globalError) {
426
+ failures.push(`Global coordinator error: ${globalError.message}`);
427
+ } finally {
428
+ await stopLocker(locker);
429
+ }
430
+
431
+ if (failures.length > 0) {
432
+ console.error(failures.join("\n"));
433
+ process.exit(1);
434
+ }
@@ -58,7 +58,7 @@ function journal(event, data = {}) {
58
58
  function outbox(type, summary, body = {}, level = "info") {
59
59
  appendFileSync(
60
60
  outboxPath,
61
- `${JSON.stringify({ to: "coordinator", from: `run:${process.env.run_id ?? "coordinator-locker"}`, type, event: type, summary, body, data: body, delivery: "followup", level, ts: new Date().toISOString() })}\n`,
61
+ `${JSON.stringify({ to: "coordinator", from: `run:${process.env.run_id ?? "locker"}`, type, event: type, summary, body, data: body, delivery: "followup", level, ts: new Date().toISOString() })}\n`,
62
62
  );
63
63
  }
64
64
  function now() {
@@ -82,7 +82,7 @@ function normalizeMessage(line) {
82
82
  try {
83
83
  return JSON.parse(trimmed);
84
84
  } catch {
85
- return { type: "coord.enqueue", body: { task: trimmed } };
85
+ return { type: "lock.enqueue", body: { task: trimmed } };
86
86
  }
87
87
  }
88
88
  function tailJournal(count) {
@@ -127,7 +127,7 @@ function nextTask(queue, locks) {
127
127
  return items.splice(index, 1)[0];
128
128
  }
129
129
  function handle(message) {
130
- const type = message.type || message.event || "coord.enqueue";
130
+ const type = message.type || message.event || "lock.enqueue";
131
131
  const body =
132
132
  message.body && typeof message.body === "object" ? message.body : message;
133
133
  let queue = readJson(queuePath, { items: [] });
@@ -135,12 +135,12 @@ function handle(message) {
135
135
  if (type === "control.stop" || type === "control.cancel") {
136
136
  writeJson(locksPath, locks);
137
137
  journal("control.stop", {});
138
- outbox("coord.stopped", "Coordinator locker stopped", {
138
+ outbox("lock.stopped", "Locker stopped", {
139
139
  queueDepth: queue.items?.length ?? 0,
140
140
  });
141
141
  process.exit(0);
142
142
  }
143
- if (type === "coord.enqueue") {
143
+ if (type === "lock.enqueue" || type === "coord.enqueue") {
144
144
  const item = {
145
145
  id: body.id || `task-${Date.now()}`,
146
146
  task: body.task ?? body,
@@ -150,20 +150,20 @@ function handle(message) {
150
150
  queue.items = [...(queue.items ?? []), item];
151
151
  writeJson(queuePath, queue);
152
152
  writeJson(locksPath, locks);
153
- journal("coord.enqueued", { id: item.id, resources: item.resources });
154
- outbox("coord.enqueued", `Queued ${item.id}`, {
153
+ journal("lock.enqueued", { id: item.id, resources: item.resources });
154
+ outbox("lock.enqueued", `Queued task ${item.id}`, {
155
155
  id: item.id,
156
156
  queueDepth: queue.items.length,
157
157
  });
158
158
  return;
159
159
  }
160
- if (type === "coord.claim") {
160
+ if (type === "lock.claim" || type === "coord.claim") {
161
161
  const owner = body.owner || message.from || "worker";
162
162
  const item = nextTask(queue, locks);
163
163
  if (!item) {
164
164
  writeJson(queuePath, queue);
165
165
  writeJson(locksPath, locks);
166
- outbox("coord.empty", "No claimable task", {
166
+ outbox("lock.empty", "No claimable task", {
167
167
  owner,
168
168
  queueDepth: queue.items?.length ?? 0,
169
169
  });
@@ -173,12 +173,12 @@ function handle(message) {
173
173
  locks[resource] = { owner, task: item.id, expiresAt: now() + leaseMs };
174
174
  writeJson(queuePath, queue);
175
175
  writeJson(locksPath, locks);
176
- journal("coord.assigned", {
176
+ journal("lock.assigned", {
177
177
  id: item.id,
178
178
  owner,
179
179
  resources: item.resources,
180
180
  });
181
- outbox("coord.assigned", `Assigned ${item.id}`, { owner, task: item });
181
+ outbox("lock.assigned", `Assigned task ${item.id}`, { owner, task: item });
182
182
  return;
183
183
  }
184
184
  if (type === "lock.acquire") {
@@ -222,20 +222,21 @@ function handle(message) {
222
222
  outbox("lock.released", `Lock released ${resource}`, { resource });
223
223
  return;
224
224
  }
225
- if (type === "coord.complete" || type === "coord.fail") {
226
- journal(type, body);
225
+ if (type === "lock.complete" || type === "lock.fail" || type === "coord.complete" || type === "coord.fail") {
226
+ const eventType = type.startsWith("coord.") ? type.replace("coord.", "lock.") : type;
227
+ journal(eventType, body);
227
228
  outbox(
228
- type,
229
- `${type} ${body.id ?? ""}`.trim(),
229
+ eventType,
230
+ `${eventType} ${body.id ?? ""}`.trim(),
230
231
  body,
231
- type === "coord.fail" ? "error" : "info",
232
+ eventType === "lock.fail" ? "error" : "info",
232
233
  );
233
234
  writeJson(locksPath, locks);
234
235
  writeJson(queuePath, queue);
235
236
  return;
236
237
  }
237
- journal("coord.unknown", { type, body });
238
- outbox("coord.unknown", `Unknown message ${type}`, { type, body }, "warning");
238
+ journal("lock.unknown", { type, body });
239
+ outbox("lock.unknown", `Unknown message ${type}`, { type, body }, "warning");
239
240
  }
240
241
 
241
242
  if (mode === "snapshot") {
@@ -250,8 +251,8 @@ if (!existsSync(controlPath)) {
250
251
  }
251
252
  writeJson(queuePath, readJson(queuePath, { items: [] }));
252
253
  writeJson(locksPath, cleanExpiredLocks(readJson(locksPath, {})));
253
- journal("coord.started", { leaseMs });
254
- outbox("coord.started", "Coordinator locker ready", { leaseMs });
254
+ journal("lock.started", { leaseMs });
255
+ outbox("lock.started", "Locker ready", { leaseMs });
255
256
 
256
257
  while (true) {
257
258
  const stream = await import("node:fs").then((fs) =>
@@ -265,8 +266,8 @@ while (true) {
265
266
  handle(message);
266
267
  } catch (error) {
267
268
  const text = error instanceof Error ? error.message : String(error);
268
- journal("coord.error", { error: text });
269
- outbox("coord.error", text, { error: text }, "error");
269
+ journal("lock.error", { error: text });
270
+ outbox("lock.error", text, { error: text }, "error");
270
271
  }
271
272
  }
272
273
  }