@llblab/pi-actors 0.22.5 → 0.24.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 (180) hide show
  1. package/AGENTS.md +125 -59
  2. package/BACKLOG.md +88 -403
  3. package/CHANGELOG.md +39 -0
  4. package/README.md +14 -1
  5. package/dist/fixtures/protocol/actor-message-branch.json +13 -0
  6. package/dist/fixtures/protocol/artifact-manifest.json +9 -0
  7. package/dist/fixtures/protocol/mailbox-contract.json +15 -0
  8. package/dist/fixtures/protocol/recipe-summary.json +16 -0
  9. package/dist/fixtures/protocol/room-message.json +11 -0
  10. package/dist/fixtures/protocol/room-roster.json +11 -0
  11. package/dist/fixtures/protocol/run-inbox-message.json +9 -0
  12. package/dist/fixtures/protocol/run-outbox-event.json +9 -0
  13. package/dist/fixtures/protocol/run-state.json +10 -0
  14. package/dist/index.js +18 -2
  15. package/dist/lib/actor-inspector-tui.d.ts +4 -0
  16. package/dist/lib/actor-inspector-tui.js +58 -44
  17. package/dist/lib/actor-rooms.d.ts +16 -0
  18. package/dist/lib/actor-rooms.js +123 -28
  19. package/dist/lib/actor-worker.d.ts +13 -0
  20. package/dist/lib/actor-worker.js +86 -0
  21. package/dist/lib/async-runner.d.ts +5 -0
  22. package/dist/lib/async-runner.js +134 -0
  23. package/dist/lib/async-runs.d.ts +34 -2
  24. package/dist/lib/async-runs.js +224 -50
  25. package/dist/lib/build-dist.d.ts +5 -0
  26. package/dist/lib/build-dist.js +24 -0
  27. package/dist/lib/command-templates.js +5 -5
  28. package/dist/lib/conformance.d.ts +12 -0
  29. package/dist/lib/conformance.js +28 -0
  30. package/dist/lib/coordinator.d.ts +5 -0
  31. package/dist/lib/coordinator.js +557 -0
  32. package/dist/lib/limits.d.ts +11 -0
  33. package/dist/lib/limits.js +11 -0
  34. package/dist/lib/locker.d.ts +5 -0
  35. package/dist/lib/locker.js +310 -0
  36. package/dist/lib/mailbox-loop.d.ts +41 -0
  37. package/dist/lib/mailbox-loop.js +62 -0
  38. package/dist/lib/observability.d.ts +2 -2
  39. package/dist/lib/observability.js +57 -57
  40. package/dist/lib/output.js +4 -5
  41. package/dist/lib/prompts.d.ts +1 -1
  42. package/dist/lib/prompts.js +1 -1
  43. package/dist/lib/recipe-discovery.js +1 -0
  44. package/dist/lib/recipe-references.d.ts +11 -2
  45. package/dist/lib/recipe-references.js +29 -4
  46. package/dist/lib/recipe-usage.d.ts +2 -1
  47. package/dist/lib/recipe-usage.js +15 -3
  48. package/dist/lib/recipe-utils.d.ts +5 -0
  49. package/dist/lib/recipe-utils.js +385 -0
  50. package/dist/lib/runtime-notifier.js +3 -7
  51. package/dist/lib/runtime.js +12 -2
  52. package/dist/lib/state-readers.d.ts +21 -0
  53. package/dist/lib/state-readers.js +74 -0
  54. package/dist/lib/tools.js +198 -39
  55. package/dist/lib/validate-recipe.d.ts +6 -0
  56. package/dist/lib/validate-recipe.js +104 -0
  57. package/dist/recipes/actor-worker.json +35 -0
  58. package/dist/recipes/coordinator-locker.json +45 -0
  59. package/dist/recipes/lens-swarm.json +66 -0
  60. package/dist/recipes/locker.json +45 -0
  61. package/dist/recipes/music-player.json +38 -0
  62. package/dist/recipes/pipeline-architect-coordinator.json +95 -0
  63. package/dist/recipes/pipeline-artifact-bundle.json +100 -0
  64. package/dist/recipes/pipeline-artifact-report.json +58 -0
  65. package/dist/recipes/pipeline-artifact-write.json +72 -0
  66. package/dist/recipes/pipeline-async-run-ops.json +70 -0
  67. package/dist/recipes/pipeline-checkpoint-continuation.json +67 -0
  68. package/dist/recipes/pipeline-development-tasking.json +81 -0
  69. package/dist/recipes/pipeline-docs-maintenance.json +80 -0
  70. package/dist/recipes/pipeline-media-library.json +59 -0
  71. package/dist/recipes/pipeline-quorum-review.json +79 -0
  72. package/dist/recipes/pipeline-release-readiness.json +110 -0
  73. package/dist/recipes/pipeline-release-summary.json +88 -0
  74. package/dist/recipes/pipeline-repo-health.json +89 -0
  75. package/dist/recipes/pipeline-research-synthesis.json +94 -0
  76. package/dist/recipes/pipeline-review-readiness.json +54 -0
  77. package/dist/recipes/pipeline-room-swarm.json +50 -0
  78. package/dist/recipes/subagent-artifact.json +32 -0
  79. package/dist/recipes/subagent-checkpoint.json +33 -0
  80. package/dist/recipes/subagent-conflict-report.json +32 -0
  81. package/dist/recipes/subagent-contradiction-map.json +33 -0
  82. package/dist/recipes/subagent-critic.json +35 -0
  83. package/dist/recipes/subagent-evidence-map.json +33 -0
  84. package/dist/recipes/subagent-followup.json +33 -0
  85. package/dist/recipes/subagent-judge.json +33 -0
  86. package/dist/recipes/subagent-merge.json +33 -0
  87. package/dist/recipes/subagent-message.json +34 -0
  88. package/dist/recipes/subagent-normalize.json +31 -0
  89. package/dist/recipes/subagent-plan.json +33 -0
  90. package/dist/recipes/subagent-prompt.json +28 -0
  91. package/dist/recipes/subagent-quorum.json +43 -0
  92. package/dist/recipes/subagent-review-coordinator.json +114 -0
  93. package/dist/recipes/subagent-review.json +37 -0
  94. package/dist/recipes/subagent-task-card.json +35 -0
  95. package/dist/recipes/subagent-tools.json +27 -0
  96. package/dist/recipes/subagent-verify.json +34 -0
  97. package/dist/recipes/subagents-prompts.json +51 -0
  98. package/dist/recipes/utility-actor-message.json +23 -0
  99. package/dist/recipes/utility-artifact-manifest.json +16 -0
  100. package/dist/recipes/utility-artifact-write.json +16 -0
  101. package/dist/recipes/utility-changelog-head.json +11 -0
  102. package/dist/recipes/utility-changelog-section.json +13 -0
  103. package/dist/recipes/utility-coordinator-lock-snapshot.json +13 -0
  104. package/dist/recipes/utility-git-log.json +11 -0
  105. package/dist/recipes/utility-git-status.json +9 -0
  106. package/dist/recipes/utility-jsonl-tail.json +10 -0
  107. package/dist/recipes/utility-markdown-index.json +14 -0
  108. package/dist/recipes/utility-package-summary.json +11 -0
  109. package/dist/recipes/utility-playlist-build.json +17 -0
  110. package/dist/recipes/utility-playlist-scan.json +11 -0
  111. package/dist/recipes/utility-run-ops-snapshot.json +17 -0
  112. package/dist/recipes/utility-run-state-files.json +13 -0
  113. package/dist/recipes/utility-run-summary.json +11 -0
  114. package/dist/recipes/utility-skill-summary.json +13 -0
  115. package/dist/recipes/utility-validate-recipe.json +13 -0
  116. package/dist/recipes/utility-validation-wrapper.json +13 -0
  117. package/dist/scripts/actor-worker.mjs +31 -0
  118. package/dist/scripts/async-runner.mjs +31 -0
  119. package/dist/scripts/build-dist.mjs +25 -0
  120. package/dist/scripts/conformance.mjs +33 -0
  121. package/dist/scripts/coordinator.mjs +31 -0
  122. package/dist/scripts/locker.mjs +33 -0
  123. package/dist/scripts/music-player.mjs +964 -0
  124. package/dist/scripts/recipe-utils.mjs +31 -0
  125. package/dist/scripts/validate-recipe.mjs +34 -0
  126. package/dist/skills/actors/SKILL.md +375 -0
  127. package/dist/skills/swarm/SKILL.md +467 -0
  128. package/dist/skills/swarm/references/development-swarm.md +596 -0
  129. package/docs/actor-messages.md +2 -2
  130. package/docs/async-runs.md +13 -1
  131. package/docs/template-recipes.md +2 -2
  132. package/docs/tool-registry.md +0 -1
  133. package/fixtures/protocol/actor-message-branch.json +13 -0
  134. package/fixtures/protocol/artifact-manifest.json +9 -0
  135. package/fixtures/protocol/mailbox-contract.json +15 -0
  136. package/fixtures/protocol/recipe-summary.json +16 -0
  137. package/fixtures/protocol/room-message.json +11 -0
  138. package/fixtures/protocol/room-roster.json +11 -0
  139. package/fixtures/protocol/run-inbox-message.json +9 -0
  140. package/fixtures/protocol/run-outbox-event.json +9 -0
  141. package/fixtures/protocol/run-state.json +10 -0
  142. package/index.ts +21 -0
  143. package/lib/actor-inspector-tui.ts +138 -59
  144. package/lib/actor-rooms.ts +241 -60
  145. package/lib/actor-worker.ts +118 -0
  146. package/lib/async-runner.ts +173 -0
  147. package/lib/async-runs.ts +302 -53
  148. package/lib/build-dist.ts +30 -0
  149. package/lib/command-templates.ts +5 -5
  150. package/lib/conformance.ts +46 -0
  151. package/lib/coordinator.ts +649 -0
  152. package/lib/limits.ts +12 -0
  153. package/lib/locker.ts +340 -0
  154. package/lib/mailbox-loop.ts +148 -0
  155. package/lib/observability.ts +34 -23
  156. package/lib/output.ts +4 -6
  157. package/lib/prompts.ts +1 -1
  158. package/lib/recipe-discovery.ts +1 -0
  159. package/lib/recipe-references.ts +57 -6
  160. package/lib/recipe-usage.ts +31 -4
  161. package/lib/recipe-utils.ts +486 -0
  162. package/lib/runtime-notifier.ts +4 -6
  163. package/lib/runtime.ts +31 -7
  164. package/lib/state-readers.ts +93 -0
  165. package/lib/tools.ts +297 -58
  166. package/lib/validate-recipe.ts +110 -0
  167. package/package.json +11 -2
  168. package/recipes/actor-worker.json +35 -0
  169. package/recipes/pipeline-quorum-review.json +12 -7
  170. package/scripts/actor-worker.mjs +31 -0
  171. package/scripts/async-runner.mjs +11 -195
  172. package/scripts/build-dist.mjs +25 -0
  173. package/scripts/conformance.mjs +33 -0
  174. package/scripts/coordinator.mjs +19 -616
  175. package/scripts/locker.mjs +23 -322
  176. package/scripts/music-player.mjs +21 -2
  177. package/scripts/recipe-utils.mjs +20 -467
  178. package/scripts/validate-recipe.mjs +23 -113
  179. package/skills/actors/SKILL.md +7 -4
  180. package/skills/swarm/SKILL.md +3 -1
@@ -9,7 +9,13 @@ import { randomUUID } from "node:crypto";
9
9
  import * as path from "node:path";
10
10
 
11
11
  import type { ActorMessage } from "./actor-messages.ts";
12
+ import * as Limits from "./limits.ts";
12
13
  import { notifyRuntimeWake } from "./runtime-notifier.ts";
14
+ import {
15
+ formatStateReadDiagnostics,
16
+ readJsonFileResilient,
17
+ readJsonlFileResilient,
18
+ } from "./state-readers.ts";
13
19
 
14
20
  const STATE_LOCK_MAX_AGE_MS = 5 * 60 * 1000;
15
21
  const STATE_LOCK_TIMEOUT_MS = 5000;
@@ -49,10 +55,21 @@ export interface RoomMessagePreview {
49
55
  type: string;
50
56
  }
51
57
 
58
+ export interface RoomCompactionInfo {
59
+ compacted_at: string;
60
+ dropped_count: number;
61
+ first_kept_at?: string;
62
+ last_kept_at?: string;
63
+ max_messages: number;
64
+ }
65
+
52
66
  export interface RoomStatus {
53
67
  room: string;
54
68
  message_count: number;
55
69
  roster_count: number;
70
+ compaction?: RoomCompactionInfo;
71
+ diagnostics?: string[];
72
+ diagnostics_count?: number;
56
73
  last_message_at?: string;
57
74
  last_message_from?: string;
58
75
  last_message_summary?: string;
@@ -105,7 +122,10 @@ function branchInboxFile(stateDir: string, branch: string): string {
105
122
  return path.join(stateDir, "branches", branch, "inbox.jsonl");
106
123
  }
107
124
 
108
- function branchIdFromAddress(address: string | undefined, run: string): string | undefined {
125
+ function branchIdFromAddress(
126
+ address: string | undefined,
127
+ run: string,
128
+ ): string | undefined {
109
129
  if (!address) return undefined;
110
130
  const match = new RegExp(`^branch:${run}/(.+)$`).exec(address);
111
131
  return match?.[1];
@@ -119,7 +139,11 @@ function sleepSync(ms: number): void {
119
139
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
120
140
  }
121
141
 
122
- function acquireStateLock(parentDir: string, name: string, label: string): () => void {
142
+ function acquireStateLock(
143
+ parentDir: string,
144
+ name: string,
145
+ label: string,
146
+ ): () => void {
123
147
  fs.mkdirSync(parentDir, { recursive: true });
124
148
  const lockDir = path.join(parentDir, name);
125
149
  const started = Date.now();
@@ -150,11 +174,19 @@ function acquireStateLock(parentDir: string, name: string, label: string): () =>
150
174
  }
151
175
 
152
176
  function acquireRoomLock(stateDir: string, room: string): () => void {
153
- return acquireStateLock(roomDir(stateDir, room), ".append.lock", `Room append ${room}`);
177
+ return acquireStateLock(
178
+ roomDir(stateDir, room),
179
+ ".append.lock",
180
+ `Room append ${room}`,
181
+ );
154
182
  }
155
183
 
156
184
  function acquireBranchInboxLock(stateDir: string, branch: string): () => void {
157
- return acquireStateLock(path.dirname(branchInboxFile(stateDir, branch)), ".inbox.lock", `Branch inbox ${branch}`);
185
+ return acquireStateLock(
186
+ path.dirname(branchInboxFile(stateDir, branch)),
187
+ ".inbox.lock",
188
+ `Branch inbox ${branch}`,
189
+ );
158
190
  }
159
191
 
160
192
  function asRecord(value: unknown): Record<string, unknown> {
@@ -169,12 +201,7 @@ function runFromRoomAddress(address: string): string | undefined {
169
201
  }
170
202
 
171
203
  function readJsonFile<T>(file: string, fallback: T): T {
172
- try {
173
- return JSON.parse(fs.readFileSync(file, "utf8")) as T;
174
- } catch (error) {
175
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return fallback;
176
- throw error;
177
- }
204
+ return readJsonFileResilient<T>(file, fallback).value;
178
205
  }
179
206
 
180
207
  function writeJsonFile(file: string, value: unknown): void {
@@ -201,7 +228,10 @@ function positiveEnvInt(name: string, fallback: number): number {
201
228
  }
202
229
 
203
230
  function roomMaxMessages(): number {
204
- return positiveEnvInt("PI_ACTORS_ROOM_MAX_MESSAGES", DEFAULT_ROOM_MAX_MESSAGES);
231
+ return positiveEnvInt(
232
+ "PI_ACTORS_ROOM_MAX_MESSAGES",
233
+ DEFAULT_ROOM_MAX_MESSAGES,
234
+ );
205
235
  }
206
236
 
207
237
  function snapshotMinIntervalMs(): number {
@@ -225,8 +255,22 @@ function compactRoomMessages(stateDir: string, room: string): void {
225
255
  if (lines.length <= maxMessages) return;
226
256
  const kept = lines.slice(-maxMessages);
227
257
  fs.writeFileSync(file, `${kept.join("\n")}\n`);
258
+ const keptMessages = kept.flatMap((line) => {
259
+ try {
260
+ return [JSON.parse(line) as RoomTimelineEntry];
261
+ } catch {
262
+ return [];
263
+ }
264
+ });
228
265
  writeJsonFile(path.join(roomDir(stateDir, room), "compaction.json"), {
229
266
  compacted_at: new Date().toISOString(),
267
+ dropped_count: lines.length - kept.length,
268
+ ...(keptMessages[0]?.received_at
269
+ ? { first_kept_at: keptMessages[0].received_at }
270
+ : {}),
271
+ ...(keptMessages.at(-1)?.received_at
272
+ ? { last_kept_at: keptMessages.at(-1)?.received_at }
273
+ : {}),
230
274
  max_messages: maxMessages,
231
275
  });
232
276
  }
@@ -242,7 +286,13 @@ function readJsonlLineCount(file: string): number {
242
286
  let count = 0;
243
287
  let lastByte: number | undefined;
244
288
  while (position < stat.size) {
245
- const bytesRead = fs.readSync(fd, chunk, 0, Math.min(chunkSize, stat.size - position), position);
289
+ const bytesRead = fs.readSync(
290
+ fd,
291
+ chunk,
292
+ 0,
293
+ Math.min(chunkSize, stat.size - position),
294
+ position,
295
+ );
246
296
  if (bytesRead <= 0) break;
247
297
  position += bytesRead;
248
298
  for (let index = 0; index < bytesRead; index += 1) {
@@ -299,7 +349,10 @@ export function readRoomRoster(
299
349
  stateDir: string,
300
350
  room: string,
301
351
  ): Record<string, RoomMember> {
302
- return readJsonFile<Record<string, RoomMember>>(rosterFile(stateDir, room), {});
352
+ return readJsonFile<Record<string, RoomMember>>(
353
+ rosterFile(stateDir, room),
354
+ {},
355
+ );
303
356
  }
304
357
 
305
358
  function writeRoomRoster(
@@ -353,29 +406,54 @@ function updateRosterForMessage(
353
406
  if (!message.from) return roster;
354
407
  const body = asRecord(message.body);
355
408
  const current = roster[message.from];
356
- const next = message.type === "actor.leave"
357
- ? {
358
- address: message.from,
359
- joined_at: current?.joined_at ?? receivedAt,
360
- last_seen: receivedAt,
361
- ...(current?.caps !== undefined ? { caps: current.caps } : {}),
362
- ...(current?.claim !== undefined ? { claim: current.claim } : {}),
363
- ...(current?.display !== undefined ? { display: current.display } : {}),
364
- ...(current?.parent !== undefined ? { parent: current.parent } : {}),
365
- ...(current?.role !== undefined ? { role: current.role } : { role: "actor" }),
366
- status: String(body.status ?? "left"),
367
- }
368
- : {
369
- address: message.from,
370
- joined_at: current?.joined_at ?? receivedAt,
371
- last_seen: receivedAt,
372
- ...(body.caps !== undefined ? { caps: body.caps } : current?.caps !== undefined ? { caps: current.caps } : {}),
373
- ...(body.claim !== undefined ? { claim: body.claim } : current?.claim !== undefined ? { claim: current.claim } : {}),
374
- ...(body.display !== undefined ? { display: body.display } : current?.display !== undefined ? { display: current.display } : {}),
375
- ...(body.parent !== undefined ? { parent: body.parent } : current?.parent !== undefined ? { parent: current.parent } : {}),
376
- ...(body.role !== undefined ? { role: body.role } : current?.role !== undefined ? { role: current.role } : { role: "actor" }),
377
- status: String(body.status ?? current?.status ?? "present"),
378
- };
409
+ const next =
410
+ message.type === "actor.leave"
411
+ ? {
412
+ address: message.from,
413
+ joined_at: current?.joined_at ?? receivedAt,
414
+ last_seen: receivedAt,
415
+ ...(current?.caps !== undefined ? { caps: current.caps } : {}),
416
+ ...(current?.claim !== undefined ? { claim: current.claim } : {}),
417
+ ...(current?.display !== undefined
418
+ ? { display: current.display }
419
+ : {}),
420
+ ...(current?.parent !== undefined ? { parent: current.parent } : {}),
421
+ ...(current?.role !== undefined
422
+ ? { role: current.role }
423
+ : { role: "actor" }),
424
+ status: String(body.status ?? "left"),
425
+ }
426
+ : {
427
+ address: message.from,
428
+ joined_at: current?.joined_at ?? receivedAt,
429
+ last_seen: receivedAt,
430
+ ...(body.caps !== undefined
431
+ ? { caps: body.caps }
432
+ : current?.caps !== undefined
433
+ ? { caps: current.caps }
434
+ : {}),
435
+ ...(body.claim !== undefined
436
+ ? { claim: body.claim }
437
+ : current?.claim !== undefined
438
+ ? { claim: current.claim }
439
+ : {}),
440
+ ...(body.display !== undefined
441
+ ? { display: body.display }
442
+ : current?.display !== undefined
443
+ ? { display: current.display }
444
+ : {}),
445
+ ...(body.parent !== undefined
446
+ ? { parent: body.parent }
447
+ : current?.parent !== undefined
448
+ ? { parent: current.parent }
449
+ : {}),
450
+ ...(body.role !== undefined
451
+ ? { role: body.role }
452
+ : current?.role !== undefined
453
+ ? { role: current.role }
454
+ : { role: "actor" }),
455
+ status: String(body.status ?? current?.status ?? "present"),
456
+ };
379
457
  roster[message.from] = next;
380
458
  if (shouldWriteRoomRosterMember(stateDir, room, current, next)) {
381
459
  writeRoomRoster(stateDir, room, roster);
@@ -384,6 +462,11 @@ function updateRosterForMessage(
384
462
  }
385
463
 
386
464
  export interface BranchInboxRecord extends ActorMessage {
465
+ claimed_at?: string;
466
+ claimed_by?: string;
467
+ error?: string;
468
+ failed_at?: string;
469
+ handled_at?: string;
387
470
  id?: string;
388
471
  queued_at?: string;
389
472
  status?: string;
@@ -401,7 +484,8 @@ function readBranchInboxFile(
401
484
  limit = 40,
402
485
  ): BranchInboxReadResult {
403
486
  const branch = branchIdFromAddress(address, run);
404
- if (!branch) throw new Error(`Expected branch:${run}/<branch>; got ${address}`);
487
+ if (!branch)
488
+ throw new Error(`Expected branch:${run}/<branch>; got ${address}`);
405
489
  try {
406
490
  const lines = readJsonlTailLines(branchInboxFile(stateDir, branch), limit);
407
491
  const messages: BranchInboxRecord[] = [];
@@ -415,7 +499,8 @@ function readBranchInboxFile(
415
499
  }
416
500
  return { corrupted, messages };
417
501
  } catch (error) {
418
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return { corrupted: 0, messages: [] };
502
+ if ((error as NodeJS.ErrnoException).code === "ENOENT")
503
+ return { corrupted: 0, messages: [] };
419
504
  throw error;
420
505
  }
421
506
  }
@@ -426,7 +511,8 @@ function readAllBranchInboxLines(
426
511
  address: string,
427
512
  ): Array<{ raw: string } | { message: BranchInboxRecord }> {
428
513
  const branch = branchIdFromAddress(address, run);
429
- if (!branch) throw new Error(`Expected branch:${run}/<branch>; got ${address}`);
514
+ if (!branch)
515
+ throw new Error(`Expected branch:${run}/<branch>; got ${address}`);
430
516
  try {
431
517
  const content = fs.readFileSync(branchInboxFile(stateDir, branch), "utf8");
432
518
  return content
@@ -464,7 +550,9 @@ export function readBranchInboxDiagnostics(
464
550
  }
465
551
 
466
552
  export function getBranchInboxTerminalRetainLimit(): number {
467
- const value = Number(process.env.PI_ACTORS_BRANCH_INBOX_TERMINAL_RETAINED ?? "");
553
+ const value = Number(
554
+ process.env.PI_ACTORS_BRANCH_INBOX_TERMINAL_RETAINED ?? "",
555
+ );
468
556
  return Number.isInteger(value) && value >= 0
469
557
  ? value
470
558
  : DEFAULT_BRANCH_INBOX_TERMINAL_RETAINED;
@@ -490,7 +578,8 @@ export function appendBranchInboxMessage(
490
578
  message: ActorMessage,
491
579
  ): void {
492
580
  const branch = branchIdFromAddress(address, run);
493
- if (!branch) throw new Error(`Expected branch:${run}/<branch>; got ${address}`);
581
+ if (!branch)
582
+ throw new Error(`Expected branch:${run}/<branch>; got ${address}`);
494
583
  const releaseLock = acquireBranchInboxLock(stateDir, branch);
495
584
  try {
496
585
  fs.writeFileSync(
@@ -507,6 +596,49 @@ export function appendBranchInboxMessage(
507
596
  }
508
597
  }
509
598
 
599
+ export function claimBranchInboxMessage(
600
+ stateDir: string,
601
+ run: string,
602
+ address: string,
603
+ owner = "runtime",
604
+ statuses: string[] = ["queued"],
605
+ ): BranchInboxRecord | undefined {
606
+ const branch = branchIdFromAddress(address, run);
607
+ if (!branch)
608
+ throw new Error(`Expected branch:${run}/<branch>; got ${address}`);
609
+ const releaseLock = acquireBranchInboxLock(stateDir, branch);
610
+ try {
611
+ const file = branchInboxFile(stateDir, branch);
612
+ const records = readAllBranchInboxLines(stateDir, run, address);
613
+ let claimed: BranchInboxRecord | undefined;
614
+ const now = new Date().toISOString();
615
+ const updated = records.map((record) => {
616
+ if (claimed || !("message" in record)) return record;
617
+ const status = record.message.status ?? "queued";
618
+ if (!statuses.includes(status)) return record;
619
+ claimed = {
620
+ ...record.message,
621
+ claimed_at: now,
622
+ claimed_by: owner,
623
+ status: "claimed",
624
+ };
625
+ return { message: claimed };
626
+ });
627
+ if (!claimed) return undefined;
628
+ fs.writeFileSync(
629
+ file,
630
+ `${updated.map((record) => ("raw" in record ? record.raw : JSON.stringify(record.message))).join("\n")}\n`,
631
+ );
632
+ notifyActorWake(stateDir, address, "branch.inbox.claim", {
633
+ id: claimed.id,
634
+ owner,
635
+ });
636
+ return claimed;
637
+ } finally {
638
+ releaseLock();
639
+ }
640
+ }
641
+
510
642
  export function updateBranchInboxMessageStatus(
511
643
  stateDir: string,
512
644
  run: string,
@@ -516,7 +648,8 @@ export function updateBranchInboxMessageStatus(
516
648
  metadata: Record<string, unknown> = {},
517
649
  ): boolean {
518
650
  const branch = branchIdFromAddress(address, run);
519
- if (!branch) throw new Error(`Expected branch:${run}/<branch>; got ${address}`);
651
+ if (!branch)
652
+ throw new Error(`Expected branch:${run}/<branch>; got ${address}`);
520
653
  const releaseLock = acquireBranchInboxLock(stateDir, branch);
521
654
  try {
522
655
  const file = branchInboxFile(stateDir, branch);
@@ -526,11 +659,22 @@ export function updateBranchInboxMessageStatus(
526
659
  const updated = records.map((record) => {
527
660
  if (!("message" in record) || record.message.id !== id) return record;
528
661
  changed = true;
529
- return { message: { ...record.message, ...metadata, [timestampKey]: new Date().toISOString(), status } };
662
+ return {
663
+ message: {
664
+ ...record.message,
665
+ ...metadata,
666
+ [timestampKey]: new Date().toISOString(),
667
+ status,
668
+ },
669
+ };
530
670
  });
531
671
  if (!changed) return false;
532
- const validMessages = updated.flatMap((record) => "message" in record ? [record.message] : []);
533
- const compactedIds = new Set(compactBranchInboxMessages(validMessages).map((message) => message.id));
672
+ const validMessages = updated.flatMap((record) =>
673
+ "message" in record ? [record.message] : [],
674
+ );
675
+ const compactedIds = new Set(
676
+ compactBranchInboxMessages(validMessages).map((message) => message.id),
677
+ );
534
678
  const lines = updated.flatMap((record) => {
535
679
  if ("raw" in record) return [record.raw];
536
680
  if (record.message.id && !compactedIds.has(record.message.id)) return [];
@@ -553,7 +697,10 @@ export function appendRoomMessage(
553
697
  try {
554
698
  const receivedAt = new Date().toISOString();
555
699
  const entry: RoomTimelineEntry = { ...message, received_at: receivedAt };
556
- fs.appendFileSync(messagesFile(stateDir, room), `${JSON.stringify(entry)}\n`);
700
+ fs.appendFileSync(
701
+ messagesFile(stateDir, room),
702
+ `${JSON.stringify(entry)}\n`,
703
+ );
557
704
  compactRoomMessages(stateDir, room);
558
705
  const roster = updateRosterForMessage(stateDir, room, message, receivedAt);
559
706
  const run = runFromRoomAddress(message.to);
@@ -585,14 +732,23 @@ export function readRoomMessages(
585
732
  ): RoomTimelineEntry[] {
586
733
  try {
587
734
  const lines = readJsonlTailLines(messagesFile(stateDir, room), limit);
588
- return lines.map((line) => JSON.parse(line));
735
+ return lines.flatMap((line) => {
736
+ try {
737
+ return [JSON.parse(line) as RoomTimelineEntry];
738
+ } catch {
739
+ return [];
740
+ }
741
+ });
589
742
  } catch (error) {
590
743
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
591
744
  throw error;
592
745
  }
593
746
  }
594
747
 
595
- function previewValue(value: unknown, maxLength = 120): string | undefined {
748
+ function previewValue(
749
+ value: unknown,
750
+ maxLength = Limits.ROOM_MESSAGE_PREVIEW_CHARS,
751
+ ): string | undefined {
596
752
  if (value === undefined) return undefined;
597
753
  const text = typeof value === "string" ? value : JSON.stringify(value);
598
754
  const compact = text.replaceAll(/\s+/g, " ").trim();
@@ -608,7 +764,9 @@ export function readRoomMessagePreviews(
608
764
  limit = 40,
609
765
  ): RoomMessagePreview[] {
610
766
  return readRoomMessages(stateDir, room, limit).map((message) => ({
611
- ...(previewValue(message.body) ? { body_preview: previewValue(message.body) } : {}),
767
+ ...(previewValue(message.body)
768
+ ? { body_preview: previewValue(message.body) }
769
+ : {}),
612
770
  ...(message.from ? { from: message.from } : {}),
613
771
  ...(message.summary ? { summary: message.summary } : {}),
614
772
  timestamp: message.received_at,
@@ -620,6 +778,22 @@ export function readRoomMessagePreviews(
620
778
  export function getRoomStatus(stateDir: string, room: string): RoomStatus {
621
779
  const messageCount = readRoomMessageCount(stateDir, room);
622
780
  const [last] = readRoomMessages(stateDir, room, 1);
781
+ const compactionRead = readJsonFileResilient<RoomCompactionInfo | undefined>(
782
+ path.join(roomDir(stateDir, room), "compaction.json"),
783
+ undefined,
784
+ );
785
+ const rosterRead = readJsonFileResilient<Record<string, RoomMember>>(
786
+ rosterFile(stateDir, room),
787
+ {},
788
+ );
789
+ const messageRead = readJsonlFileResilient<RoomTimelineEntry>(
790
+ messagesFile(stateDir, room),
791
+ );
792
+ const diagnostics = [
793
+ ...messageRead.diagnostics,
794
+ ...rosterRead.diagnostics,
795
+ ...compactionRead.diagnostics,
796
+ ];
623
797
  return {
624
798
  ...(last
625
799
  ? {
@@ -629,9 +803,16 @@ export function getRoomStatus(stateDir: string, room: string): RoomStatus {
629
803
  last_message_type: last.type,
630
804
  }
631
805
  : {}),
806
+ ...(compactionRead.value ? { compaction: compactionRead.value } : {}),
807
+ ...(diagnostics.length
808
+ ? {
809
+ diagnostics: formatStateReadDiagnostics(diagnostics),
810
+ diagnostics_count: diagnostics.length,
811
+ }
812
+ : {}),
632
813
  message_count: messageCount,
633
814
  room,
634
- roster_count: Object.keys(readRoomRoster(stateDir, room)).length,
815
+ roster_count: Object.keys(rosterRead.value).length,
635
816
  };
636
817
  }
637
818
 
@@ -661,7 +842,10 @@ export function ensureRoomMember(
661
842
  });
662
843
  }
663
844
 
664
- export function ensureDefaultRoom(stateDir: string, run: string): RoomAppendResult {
845
+ export function ensureDefaultRoom(
846
+ stateDir: string,
847
+ run: string,
848
+ ): RoomAppendResult {
665
849
  return ensureRoomMember(
666
850
  stateDir,
667
851
  run,
@@ -675,14 +859,10 @@ export function ensureDefaultRoom(stateDir: string, run: string): RoomAppendResu
675
859
  export function readCommunicationSnapshot(
676
860
  stateDir: string,
677
861
  ): ActorCommunicationSnapshot | undefined {
678
- try {
679
- return JSON.parse(
680
- fs.readFileSync(snapshotFile(stateDir), "utf8"),
681
- ) as ActorCommunicationSnapshot;
682
- } catch (error) {
683
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
684
- throw error;
685
- }
862
+ return readJsonFileResilient<ActorCommunicationSnapshot | undefined>(
863
+ snapshotFile(stateDir),
864
+ undefined,
865
+ ).value;
686
866
  }
687
867
 
688
868
  export function readRoomContacts(
@@ -753,6 +933,7 @@ function writeBranchCommunicationSnapshotDebounced(
753
933
  ): ActorCommunicationSnapshot | undefined {
754
934
  const branch = branchIdFromAddress(self, run);
755
935
  if (!branch) throw new Error(`Expected branch:${run}/<branch>; got ${self}`);
756
- if (shouldDebounceSnapshot(branchSnapshotFile(stateDir, branch))) return undefined;
936
+ if (shouldDebounceSnapshot(branchSnapshotFile(stateDir, branch)))
937
+ return undefined;
757
938
  return writeBranchCommunicationSnapshot(stateDir, run, self);
758
939
  }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Canonical mailbox-backed actor worker entrypoint logic.
3
+ * Zones: worker demo, mailbox loop recipe runtime
4
+ */
5
+
6
+ import { appendFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+
9
+ import { appendRoomMessage, ensureRoomMember } from "./actor-rooms.ts";
10
+ import { handleMailboxLoopOnce, isMailboxLoopStopMessage } from "./mailbox-loop.ts";
11
+
12
+ interface ActorWorkerArgs {
13
+ branch?: string;
14
+ poll_ms?: string | number;
15
+ run?: string;
16
+ state_dir?: string;
17
+ }
18
+
19
+ export function parseActorWorkerArgs(argv: string[]): ActorWorkerArgs {
20
+ const args: Record<string, string | true> = {};
21
+ for (let index = 0; index < argv.length; index += 1) {
22
+ const token = argv[index];
23
+ if (!token.startsWith("--")) continue;
24
+ const key = token.slice(2).replaceAll("-", "_");
25
+ const next = argv[index + 1];
26
+ if (!next || next.startsWith("--")) {
27
+ args[key] = true;
28
+ continue;
29
+ }
30
+ args[key] = next;
31
+ index += 1;
32
+ }
33
+ return args as ActorWorkerArgs;
34
+ }
35
+
36
+ function sleep(ms: number): Promise<void> {
37
+ return new Promise((resolve) => setTimeout(resolve, ms));
38
+ }
39
+
40
+ function text(value: unknown): string {
41
+ return typeof value === "string" ? value : JSON.stringify(value);
42
+ }
43
+
44
+ export async function runActorWorker(argv = process.argv.slice(2)): Promise<void> {
45
+ const args = parseActorWorkerArgs(argv);
46
+ const stateDir = String(args.state_dir ?? "");
47
+ const run = String(args.run ?? "");
48
+ const branch = String(args.branch ?? "worker");
49
+ const pollMs = Math.max(50, Number(args.poll_ms ?? 1000));
50
+
51
+ if (!stateDir || !run) {
52
+ throw new Error("usage: actor-worker.mjs --state-dir <dir> --run <id> [--branch worker] [--poll-ms 1000]");
53
+ }
54
+
55
+ const branchAddress = `branch:${run}/${branch}`;
56
+ const roomAddress = `room:${run}`;
57
+ const journalPath = join(stateDir, "worker-events.jsonl");
58
+
59
+ function journal(event: string, data: Record<string, unknown> = {}): void {
60
+ appendFileSync(
61
+ journalPath,
62
+ `${JSON.stringify({ event, ts: new Date().toISOString(), ...data })}\n`,
63
+ );
64
+ }
65
+
66
+ function room(type: string, summary: string, body: Record<string, unknown> = {}): void {
67
+ appendRoomMessage(stateDir, "main", {
68
+ body,
69
+ from: branchAddress,
70
+ summary,
71
+ to: roomAddress,
72
+ type,
73
+ });
74
+ }
75
+
76
+ ensureRoomMember(
77
+ stateDir,
78
+ run,
79
+ "main",
80
+ branchAddress,
81
+ { display: branch, role: "worker", status: "present" },
82
+ `${branch} joined as mailbox worker`,
83
+ );
84
+ room("awaiting_assignment", `${branch} awaiting assignment`, { branch });
85
+ journal("worker.started", { branch, run });
86
+
87
+ let stopping = false;
88
+ while (!stopping) {
89
+ const result = await handleMailboxLoopOnce(
90
+ { address: branchAddress, kind: "branch", run, stateDir },
91
+ (message) => {
92
+ if (isMailboxLoopStopMessage(message)) {
93
+ stopping = true;
94
+ room("actor.leave", `${branch} stopping`, { branch, reason: message.type });
95
+ journal("worker.stopping", { id: message.id, type: message.type });
96
+ return;
97
+ }
98
+ room("task.claim", `${branch} claimed ${message.type}`, {
99
+ branch,
100
+ id: message.id,
101
+ type: message.type,
102
+ });
103
+ room("task.result", `${branch} handled ${message.type}`, {
104
+ branch,
105
+ id: message.id,
106
+ result: text(message.body),
107
+ type: message.type,
108
+ });
109
+ room("awaiting_assignment", `${branch} awaiting assignment`, { branch });
110
+ journal("task.handled", { id: message.id, type: message.type });
111
+ },
112
+ { owner: branchAddress },
113
+ );
114
+ if (!result.handled) await sleep(pollMs);
115
+ }
116
+
117
+ journal("worker.done", { branch, run });
118
+ }