@llblab/pi-actors 0.23.0 → 0.24.1

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 (158) hide show
  1. package/AGENTS.md +125 -60
  2. package/BACKLOG.md +133 -176
  3. package/CHANGELOG.md +27 -0
  4. package/README.md +13 -0
  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 +3 -2
  15. package/dist/lib/actor-inspector-tui.js +5 -32
  16. package/dist/lib/actor-rooms.d.ts +8 -0
  17. package/dist/lib/actor-rooms.js +64 -20
  18. package/dist/lib/actor-worker.d.ts +13 -0
  19. package/dist/lib/actor-worker.js +86 -0
  20. package/dist/lib/async-runner.d.ts +5 -0
  21. package/dist/lib/async-runner.js +134 -0
  22. package/dist/lib/async-runs.js +9 -28
  23. package/dist/lib/conformance.d.ts +12 -0
  24. package/dist/lib/conformance.js +28 -0
  25. package/dist/lib/coordinator.d.ts +5 -0
  26. package/dist/lib/coordinator.js +574 -0
  27. package/dist/lib/locker.d.ts +5 -0
  28. package/dist/lib/locker.js +310 -0
  29. package/dist/lib/mailbox-loop.d.ts +41 -0
  30. package/dist/lib/mailbox-loop.js +62 -0
  31. package/dist/lib/observability.d.ts +2 -2
  32. package/dist/lib/observability.js +51 -53
  33. package/dist/lib/prompts.d.ts +1 -1
  34. package/dist/lib/prompts.js +1 -1
  35. package/dist/lib/recipe-references.js +25 -2
  36. package/dist/lib/recipe-utils.d.ts +5 -0
  37. package/dist/lib/recipe-utils.js +385 -0
  38. package/dist/lib/runtime-notifier.js +3 -7
  39. package/dist/lib/state-readers.d.ts +21 -0
  40. package/dist/lib/state-readers.js +74 -0
  41. package/dist/lib/tools.js +1 -1
  42. package/dist/lib/validate-recipe.d.ts +6 -0
  43. package/dist/lib/validate-recipe.js +104 -0
  44. package/dist/recipes/actor-worker.json +35 -0
  45. package/dist/recipes/coordinator-locker.json +45 -0
  46. package/dist/recipes/lens-swarm.json +66 -0
  47. package/dist/recipes/locker.json +45 -0
  48. package/dist/recipes/music-player.json +38 -0
  49. package/dist/recipes/pipeline-architect-coordinator.json +95 -0
  50. package/dist/recipes/pipeline-artifact-bundle.json +100 -0
  51. package/dist/recipes/pipeline-artifact-report.json +58 -0
  52. package/dist/recipes/pipeline-artifact-write.json +72 -0
  53. package/dist/recipes/pipeline-async-run-ops.json +70 -0
  54. package/dist/recipes/pipeline-checkpoint-continuation.json +67 -0
  55. package/dist/recipes/pipeline-development-tasking.json +81 -0
  56. package/dist/recipes/pipeline-docs-maintenance.json +80 -0
  57. package/dist/recipes/pipeline-media-library.json +59 -0
  58. package/dist/recipes/pipeline-quorum-review.json +79 -0
  59. package/dist/recipes/pipeline-release-readiness.json +110 -0
  60. package/dist/recipes/pipeline-release-summary.json +88 -0
  61. package/dist/recipes/pipeline-repo-health.json +89 -0
  62. package/dist/recipes/pipeline-research-synthesis.json +94 -0
  63. package/dist/recipes/pipeline-review-readiness.json +54 -0
  64. package/dist/recipes/pipeline-room-swarm.json +50 -0
  65. package/dist/recipes/subagent-artifact.json +32 -0
  66. package/dist/recipes/subagent-checkpoint.json +33 -0
  67. package/dist/recipes/subagent-conflict-report.json +32 -0
  68. package/dist/recipes/subagent-contradiction-map.json +33 -0
  69. package/dist/recipes/subagent-critic.json +35 -0
  70. package/dist/recipes/subagent-evidence-map.json +33 -0
  71. package/dist/recipes/subagent-followup.json +33 -0
  72. package/dist/recipes/subagent-judge.json +33 -0
  73. package/dist/recipes/subagent-merge.json +33 -0
  74. package/dist/recipes/subagent-message.json +34 -0
  75. package/dist/recipes/subagent-normalize.json +31 -0
  76. package/dist/recipes/subagent-plan.json +33 -0
  77. package/dist/recipes/subagent-prompt.json +28 -0
  78. package/dist/recipes/subagent-quorum.json +43 -0
  79. package/dist/recipes/subagent-review-coordinator.json +114 -0
  80. package/dist/recipes/subagent-review.json +37 -0
  81. package/dist/recipes/subagent-task-card.json +35 -0
  82. package/dist/recipes/subagent-tools.json +27 -0
  83. package/dist/recipes/subagent-verify.json +34 -0
  84. package/dist/recipes/subagents-prompts.json +51 -0
  85. package/dist/recipes/utility-actor-message.json +23 -0
  86. package/dist/recipes/utility-artifact-manifest.json +16 -0
  87. package/dist/recipes/utility-artifact-write.json +16 -0
  88. package/dist/recipes/utility-changelog-head.json +11 -0
  89. package/dist/recipes/utility-changelog-section.json +13 -0
  90. package/dist/recipes/utility-coordinator-lock-snapshot.json +13 -0
  91. package/dist/recipes/utility-git-log.json +11 -0
  92. package/dist/recipes/utility-git-status.json +9 -0
  93. package/dist/recipes/utility-jsonl-tail.json +10 -0
  94. package/dist/recipes/utility-markdown-index.json +14 -0
  95. package/dist/recipes/utility-package-summary.json +11 -0
  96. package/dist/recipes/utility-playlist-build.json +17 -0
  97. package/dist/recipes/utility-playlist-scan.json +11 -0
  98. package/dist/recipes/utility-run-ops-snapshot.json +17 -0
  99. package/dist/recipes/utility-run-state-files.json +13 -0
  100. package/dist/recipes/utility-run-summary.json +11 -0
  101. package/dist/recipes/utility-skill-summary.json +13 -0
  102. package/dist/recipes/utility-validate-recipe.json +13 -0
  103. package/dist/recipes/utility-validation-wrapper.json +13 -0
  104. package/dist/scripts/actor-worker.mjs +31 -0
  105. package/dist/scripts/async-runner.mjs +31 -0
  106. package/dist/scripts/build-dist.mjs +33 -0
  107. package/dist/scripts/conformance.mjs +33 -0
  108. package/dist/scripts/coordinator.mjs +31 -0
  109. package/dist/scripts/locker.mjs +33 -0
  110. package/dist/scripts/music-player.mjs +964 -0
  111. package/dist/scripts/recipe-utils.mjs +31 -0
  112. package/dist/scripts/validate-recipe.mjs +34 -0
  113. package/dist/skills/actors/SKILL.md +377 -0
  114. package/dist/skills/swarm/SKILL.md +467 -0
  115. package/dist/skills/swarm/references/development-swarm.md +596 -0
  116. package/docs/actor-messages.md +2 -2
  117. package/docs/async-runs.md +11 -0
  118. package/docs/template-recipes.md +1 -1
  119. package/fixtures/protocol/actor-message-branch.json +13 -0
  120. package/fixtures/protocol/artifact-manifest.json +9 -0
  121. package/fixtures/protocol/mailbox-contract.json +15 -0
  122. package/fixtures/protocol/recipe-summary.json +16 -0
  123. package/fixtures/protocol/room-message.json +11 -0
  124. package/fixtures/protocol/room-roster.json +11 -0
  125. package/fixtures/protocol/run-inbox-message.json +9 -0
  126. package/fixtures/protocol/run-outbox-event.json +9 -0
  127. package/fixtures/protocol/run-state.json +10 -0
  128. package/index.ts +3 -0
  129. package/lib/actor-inspector-tui.ts +11 -34
  130. package/lib/actor-rooms.ts +88 -18
  131. package/lib/actor-worker.ts +118 -0
  132. package/lib/async-runner.ts +173 -0
  133. package/lib/async-runs.ts +12 -22
  134. package/lib/conformance.ts +46 -0
  135. package/lib/coordinator.ts +664 -0
  136. package/lib/locker.ts +340 -0
  137. package/lib/mailbox-loop.ts +148 -0
  138. package/lib/observability.ts +24 -19
  139. package/lib/prompts.ts +1 -1
  140. package/lib/recipe-references.ts +37 -2
  141. package/lib/recipe-utils.ts +486 -0
  142. package/lib/runtime-notifier.ts +4 -6
  143. package/lib/state-readers.ts +93 -0
  144. package/lib/tools.ts +1 -1
  145. package/lib/validate-recipe.ts +110 -0
  146. package/package.json +10 -2
  147. package/recipes/actor-worker.json +35 -0
  148. package/recipes/pipeline-quorum-review.json +12 -7
  149. package/scripts/actor-worker.mjs +31 -0
  150. package/scripts/async-runner.mjs +11 -201
  151. package/scripts/build-dist.mjs +33 -0
  152. package/scripts/conformance.mjs +21 -35
  153. package/scripts/coordinator.mjs +15 -625
  154. package/scripts/locker.mjs +20 -332
  155. package/scripts/recipe-utils.mjs +17 -477
  156. package/scripts/validate-recipe.mjs +18 -121
  157. package/skills/actors/SKILL.md +8 -3
  158. package/skills/swarm/SKILL.md +3 -1
@@ -0,0 +1,11 @@
1
+ {
2
+ "to": "room:demo",
3
+ "from": "branch:demo/worker",
4
+ "type": "task.result",
5
+ "summary": "Worker completed task",
6
+ "body": {
7
+ "id": "inbox-001",
8
+ "result": "ok"
9
+ },
10
+ "received_at": "2026-05-26T00:00:00.000Z"
11
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "room": "main",
3
+ "members": {
4
+ "branch:demo/worker": {
5
+ "display": "worker",
6
+ "role": "worker",
7
+ "status": "present",
8
+ "last_seen": "2026-05-26T00:00:00.000Z"
9
+ }
10
+ }
11
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "id": "inbox-001",
3
+ "status": "queued",
4
+ "queued_at": "2026-05-26T00:00:00.000Z",
5
+ "to": "run:demo",
6
+ "from": "coordinator",
7
+ "type": "control.continue",
8
+ "body": "continue"
9
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "id": "event-001",
3
+ "type": "task.result",
4
+ "summary": "Worker completed task",
5
+ "body": {
6
+ "result": "ok"
7
+ },
8
+ "created_at": "2026-05-26T00:00:00.000Z"
9
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "run": "demo",
3
+ "state_dir": "{state_dir}",
4
+ "status": "running",
5
+ "template": "echo demo",
6
+ "values": {
7
+ "run_id": "demo",
8
+ "state_dir": "{state_dir}"
9
+ }
10
+ }
package/index.ts CHANGED
@@ -48,6 +48,7 @@ export default function toolRegistryExtension(pi: ExtensionAPI) {
48
48
  const runDirWatchers = new Map<string, FSWatcher>();
49
49
  const observedRuns = new Map<string, Observability.RunObservedStatus>();
50
50
  const observedRunEventLines = new Map<string, number>();
51
+ const observedRunOutboxEventIds = new Map<string, Set<string>>();
51
52
  const retirementAttempts = new Set<string>();
52
53
  let runStatusFrame = 0;
53
54
  let communicationWidgetVisible = false;
@@ -152,6 +153,7 @@ export default function toolRegistryExtension(pi: ExtensionAPI) {
152
153
  const outboxEvents = Observability.detectRunOutboxEvents(
153
154
  observedRunEventLines,
154
155
  summary,
156
+ observedRunOutboxEventIds,
155
157
  );
156
158
  if (!notify) return;
157
159
  retireCandidateRuns(ctx, summary);
@@ -177,6 +179,7 @@ export default function toolRegistryExtension(pi: ExtensionAPI) {
177
179
  observedRunEventLines,
178
180
  summary,
179
181
  transitions.map((transition) => transition.stateDir ?? transition.run),
182
+ observedRunOutboxEventIds,
180
183
  );
181
184
  for (const event of outboxEvents) {
182
185
  if (!Observability.shouldNotifyRunOutboxEvent(event)) continue;
@@ -11,6 +11,7 @@ import { visibleWidth } from "@earendil-works/pi-tui";
11
11
  import type { ActorMessage } from "./actor-messages.ts";
12
12
  import * as Limits from "./limits.ts";
13
13
  import * as Paths from "./paths.ts";
14
+ import { readJsonFileResilient, readJsonlFileResilient } from "./state-readers.ts";
14
15
 
15
16
  export interface ActorInspectorPreview {
16
17
  body_preview?: string;
@@ -72,22 +73,7 @@ function asRecord(value: unknown): Record<string, unknown> {
72
73
  }
73
74
 
74
75
  function readJsonLines(file: string): Record<string, unknown>[] {
75
- try {
76
- return fs
77
- .readFileSync(file, "utf8")
78
- .split("\n")
79
- .filter(Boolean)
80
- .flatMap((line) => {
81
- try {
82
- return [JSON.parse(line) as Record<string, unknown>];
83
- } catch {
84
- return [];
85
- }
86
- });
87
- } catch (error) {
88
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
89
- return [];
90
- }
76
+ return readJsonlFileResilient<Record<string, unknown>>(file).records;
91
77
  }
92
78
 
93
79
  function previewValue(
@@ -156,16 +142,10 @@ function readRoomRosterRecords(
156
142
  stateDir: string,
157
143
  room: string,
158
144
  ): Record<string, Record<string, unknown>> {
159
- try {
160
- return JSON.parse(
161
- fs.readFileSync(
162
- path.join(stateDir, "rooms", room, "roster.json"),
163
- "utf8",
164
- ),
165
- ) as Record<string, Record<string, unknown>>;
166
- } catch {
167
- return {};
168
- }
145
+ return readJsonFileResilient<Record<string, Record<string, unknown>>>(
146
+ path.join(stateDir, "rooms", room, "roster.json"),
147
+ {},
148
+ ).value;
169
149
  }
170
150
 
171
151
  function memberDisplay(
@@ -297,14 +277,11 @@ function readOutboxPreviews(
297
277
  }
298
278
 
299
279
  function getRunOwnerId(stateDir: string): string | undefined {
300
- try {
301
- const meta = JSON.parse(
302
- fs.readFileSync(path.join(stateDir, "run.json"), "utf8"),
303
- ) as Record<string, unknown>;
304
- return typeof meta.ownerId === "string" ? meta.ownerId : undefined;
305
- } catch {
306
- return undefined;
307
- }
280
+ const meta = readJsonFileResilient<Record<string, unknown> | undefined>(
281
+ path.join(stateDir, "run.json"),
282
+ undefined,
283
+ ).value;
284
+ return typeof meta?.ownerId === "string" ? meta.ownerId : undefined;
308
285
  }
309
286
 
310
287
  function matchesOwner(stateDir: string, ownerId: string | undefined): boolean {
@@ -11,6 +11,11 @@ import * as path from "node:path";
11
11
  import type { ActorMessage } from "./actor-messages.ts";
12
12
  import * as Limits from "./limits.ts";
13
13
  import { notifyRuntimeWake } from "./runtime-notifier.ts";
14
+ import {
15
+ formatStateReadDiagnostics,
16
+ readJsonFileResilient,
17
+ readJsonlFileResilient,
18
+ } from "./state-readers.ts";
14
19
 
15
20
  const STATE_LOCK_MAX_AGE_MS = 5 * 60 * 1000;
16
21
  const STATE_LOCK_TIMEOUT_MS = 5000;
@@ -63,6 +68,8 @@ export interface RoomStatus {
63
68
  message_count: number;
64
69
  roster_count: number;
65
70
  compaction?: RoomCompactionInfo;
71
+ diagnostics?: string[];
72
+ diagnostics_count?: number;
66
73
  last_message_at?: string;
67
74
  last_message_from?: string;
68
75
  last_message_summary?: string;
@@ -194,12 +201,7 @@ function runFromRoomAddress(address: string): string | undefined {
194
201
  }
195
202
 
196
203
  function readJsonFile<T>(file: string, fallback: T): T {
197
- try {
198
- return JSON.parse(fs.readFileSync(file, "utf8")) as T;
199
- } catch (error) {
200
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return fallback;
201
- throw error;
202
- }
204
+ return readJsonFileResilient<T>(file, fallback).value;
203
205
  }
204
206
 
205
207
  function writeJsonFile(file: string, value: unknown): void {
@@ -460,6 +462,11 @@ function updateRosterForMessage(
460
462
  }
461
463
 
462
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;
463
470
  id?: string;
464
471
  queued_at?: string;
465
472
  status?: string;
@@ -589,6 +596,49 @@ export function appendBranchInboxMessage(
589
596
  }
590
597
  }
591
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
+
592
642
  export function updateBranchInboxMessageStatus(
593
643
  stateDir: string,
594
644
  run: string,
@@ -682,7 +732,13 @@ export function readRoomMessages(
682
732
  ): RoomTimelineEntry[] {
683
733
  try {
684
734
  const lines = readJsonlTailLines(messagesFile(stateDir, room), limit);
685
- 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
+ });
686
742
  } catch (error) {
687
743
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
688
744
  throw error;
@@ -722,10 +778,22 @@ export function readRoomMessagePreviews(
722
778
  export function getRoomStatus(stateDir: string, room: string): RoomStatus {
723
779
  const messageCount = readRoomMessageCount(stateDir, room);
724
780
  const [last] = readRoomMessages(stateDir, room, 1);
725
- const compaction = readJsonFile<RoomCompactionInfo | undefined>(
781
+ const compactionRead = readJsonFileResilient<RoomCompactionInfo | undefined>(
726
782
  path.join(roomDir(stateDir, room), "compaction.json"),
727
783
  undefined,
728
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
+ ];
729
797
  return {
730
798
  ...(last
731
799
  ? {
@@ -735,10 +803,16 @@ export function getRoomStatus(stateDir: string, room: string): RoomStatus {
735
803
  last_message_type: last.type,
736
804
  }
737
805
  : {}),
738
- ...(compaction ? { compaction } : {}),
806
+ ...(compactionRead.value ? { compaction: compactionRead.value } : {}),
807
+ ...(diagnostics.length
808
+ ? {
809
+ diagnostics: formatStateReadDiagnostics(diagnostics),
810
+ diagnostics_count: diagnostics.length,
811
+ }
812
+ : {}),
739
813
  message_count: messageCount,
740
814
  room,
741
- roster_count: Object.keys(readRoomRoster(stateDir, room)).length,
815
+ roster_count: Object.keys(rosterRead.value).length,
742
816
  };
743
817
  }
744
818
 
@@ -785,14 +859,10 @@ export function ensureDefaultRoom(
785
859
  export function readCommunicationSnapshot(
786
860
  stateDir: string,
787
861
  ): ActorCommunicationSnapshot | undefined {
788
- try {
789
- return JSON.parse(
790
- fs.readFileSync(snapshotFile(stateDir), "utf8"),
791
- ) as ActorCommunicationSnapshot;
792
- } catch (error) {
793
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
794
- throw error;
795
- }
862
+ return readJsonFileResilient<ActorCommunicationSnapshot | undefined>(
863
+ snapshotFile(stateDir),
864
+ undefined,
865
+ ).value;
796
866
  }
797
867
 
798
868
  export function readRoomContacts(
@@ -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
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Detached async-runner entrypoint logic.
3
+ * Zones: async run process, command-template execution telemetry
4
+ */
5
+
6
+ import { appendFileSync, readFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+
9
+ import { appendRecipeContextToPiArgs } from "./actor-recipe-context.ts";
10
+ import { execCommandTemplate } from "./command-templates.ts";
11
+ import { executeRegisteredTool, type ToolExecOptions } from "./execution.ts";
12
+ import { writeJsonAtomic } from "./file-state.ts";
13
+
14
+ function quoteCommandDetailPart(value: string): string {
15
+ if (value === "") return "''";
16
+ if (/^[A-Za-z0-9_/:=.,@%+\-]+$/.test(value)) return value;
17
+ return `'${String(value).replaceAll("'", "'\\''")}'`;
18
+ }
19
+
20
+ function formatCommandDetail(command: string, args: string[]): string {
21
+ return [command, ...args].map(quoteCommandDetailPart).join(" ");
22
+ }
23
+
24
+ function summarizeCommandDetail(commandDetail: string): string {
25
+ return commandDetail.length > 160
26
+ ? `${commandDetail.slice(0, 157)}...`
27
+ : commandDetail;
28
+ }
29
+
30
+ export async function runAsyncRunner(stateDir = process.argv[2]): Promise<void> {
31
+ if (!stateDir) throw new Error("missing state dir");
32
+
33
+ const runPath = join(stateDir, "run.json");
34
+ const progressPath = join(stateDir, "progress.json");
35
+ const resultPath = join(stateDir, "result.json");
36
+ const eventsPath = join(stateDir, "events.jsonl");
37
+ const outboxPath = join(stateDir, "outbox.jsonl");
38
+ const stdoutPath = join(stateDir, "stdout.log");
39
+ const stderrPath = join(stateDir, "stderr.log");
40
+ const meta = JSON.parse(readFileSync(runPath, "utf8"));
41
+
42
+ function event(name: string, data: Record<string, unknown> = {}): void {
43
+ appendFileSync(
44
+ eventsPath,
45
+ `${JSON.stringify({ event: name, ts: new Date().toISOString(), ...data })}\n`,
46
+ );
47
+ }
48
+
49
+ function outbox(
50
+ name: string,
51
+ summary: string,
52
+ data: Record<string, unknown> = {},
53
+ delivery = "log",
54
+ level = "info",
55
+ ): void {
56
+ appendFileSync(
57
+ outboxPath,
58
+ `${JSON.stringify({ body: data, data, delivery, event: name, from: `run:${meta.run}`, level, summary, to: "coordinator", ts: new Date().toISOString(), type: name })}\n`,
59
+ );
60
+ }
61
+
62
+ function progress(phase: string, extra: Record<string, unknown> = {}): void {
63
+ writeJsonAtomic(progressPath, {
64
+ phase,
65
+ updatedAt: new Date().toISOString(),
66
+ ...extra,
67
+ });
68
+ }
69
+
70
+ let activeSubagents = 0;
71
+ let completedSubagents = 0;
72
+ const subagentFailures: Record<string, unknown>[] = [];
73
+
74
+ function getCommandDoneDelivery(result: { code: number }): string {
75
+ return result.code !== 0 || activeSubagents > 0 ? "followup" : "log";
76
+ }
77
+
78
+ function progressRunning(): void {
79
+ progress("running", {
80
+ activeSubagents,
81
+ completed: completedSubagents,
82
+ failures: subagentFailures,
83
+ });
84
+ }
85
+
86
+ async function observedExec(command: string, args: string[], options?: ToolExecOptions) {
87
+ const commandDetail = formatCommandDetail(command, args);
88
+ const execArgs = appendRecipeContextToPiArgs(
89
+ command,
90
+ args,
91
+ meta.recipe_context_records,
92
+ options?.actorRecipeContext,
93
+ );
94
+ activeSubagents += 1;
95
+ event("command.start", { activeSubagents, command: commandDetail });
96
+ progressRunning();
97
+ const result = await execCommandTemplate(command, execArgs, options);
98
+ activeSubagents = Math.max(0, activeSubagents - 1);
99
+ completedSubagents += 1;
100
+ if (result.code !== 0) {
101
+ subagentFailures.push({
102
+ code: result.code,
103
+ command: commandDetail,
104
+ killed: result.killed,
105
+ });
106
+ }
107
+ event("command.done", {
108
+ activeSubagents,
109
+ code: result.code,
110
+ command: commandDetail,
111
+ killed: result.killed,
112
+ });
113
+ outbox(
114
+ "command.done",
115
+ `Command ${summarizeCommandDetail(commandDetail)} completed with code ${result.code}`,
116
+ {
117
+ activeSubagents,
118
+ ...(meta.artifacts ? { artifacts: meta.artifacts } : {}),
119
+ run_files: [stdoutPath, stderrPath, resultPath, eventsPath, outboxPath],
120
+ code: result.code,
121
+ command: commandDetail,
122
+ killed: result.killed,
123
+ },
124
+ getCommandDoneDelivery(result),
125
+ result.code === 0 ? "info" : "error",
126
+ );
127
+ progressRunning();
128
+ return result;
129
+ }
130
+
131
+ try {
132
+ event("run.runner.start", { pid: process.pid });
133
+ progressRunning();
134
+ const result = await executeRegisteredTool(
135
+ {
136
+ name: "run_actor",
137
+ description: "Detached command-template run actor",
138
+ template: meta.template,
139
+ args: [],
140
+ defaults: {},
141
+ },
142
+ meta.values || {},
143
+ observedExec,
144
+ meta.cwd,
145
+ );
146
+ const text = result.content?.[0]?.text || "";
147
+ appendFileSync(stdoutPath, text);
148
+ writeJsonAtomic(resultPath, {
149
+ code: result.details.code,
150
+ command: result.details.command,
151
+ killed: result.details.killed,
152
+ truncated: result.details.truncated,
153
+ completedAt: new Date().toISOString(),
154
+ });
155
+ progress("done", {
156
+ completed: 1,
157
+ failures: result.details.nonCriticalFailures || [],
158
+ });
159
+ event("run.done", { code: result.details.code });
160
+ } catch (error) {
161
+ const message = error instanceof Error ? error.message : String(error);
162
+ appendFileSync(stderrPath, `${message}\n`);
163
+ writeJsonAtomic(resultPath, {
164
+ code: 1,
165
+ error: message,
166
+ killed: false,
167
+ completedAt: new Date().toISOString(),
168
+ });
169
+ progress("failed", { completed: 0, failures: [{ message }] });
170
+ event("run.failed", { error: message });
171
+ throw error;
172
+ }
173
+ }
package/lib/async-runs.ts CHANGED
@@ -33,6 +33,7 @@ import type {
33
33
  } from "./command-templates.ts";
34
34
  import { substituteCommandTemplateToken } from "./command-templates.ts";
35
35
  import { writeJsonAtomic } from "./file-state.ts";
36
+ import { readJsonFileResilient, readJsonlFileResilient } from "./state-readers.ts";
36
37
  import * as Paths from "./paths.ts";
37
38
  import * as RecipeReferences from "./recipe-references.ts";
38
39
  import * as RecipeUsage from "./recipe-usage.ts";
@@ -345,11 +346,10 @@ function resolveStartParams(params: AsyncRunStartParams): AsyncRunStartParams {
345
346
  }
346
347
 
347
348
  function readJson(path: string): Record<string, unknown> | undefined {
348
- try {
349
- return JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
350
- } catch {
351
- return undefined;
352
- }
349
+ return readJsonFileResilient<Record<string, unknown> | undefined>(
350
+ path,
351
+ undefined,
352
+ ).value;
353
353
  }
354
354
 
355
355
  function isAlive(pid: number): boolean {
@@ -392,16 +392,12 @@ function tailLines(path: string, lines: number): string[] {
392
392
  function getInterruptedRunStatus(
393
393
  stateDir: string,
394
394
  ): "cancelled" | "killed" | undefined {
395
- const events = tailFile(join(stateDir, "events.jsonl"), 200);
396
- if (!events) return undefined;
397
- for (const line of events.split("\n").reverse()) {
398
- try {
399
- const event = JSON.parse(line) as Record<string, unknown>;
400
- if (event.event === "run.kill") return "killed";
401
- if (event.event === "run.cancel") return "cancelled";
402
- } catch {
403
- // Ignore malformed event lines.
404
- }
395
+ const events = readJsonlFileResilient<Record<string, unknown>>(
396
+ join(stateDir, "events.jsonl"),
397
+ ).records.slice(-200);
398
+ for (const event of events.reverse()) {
399
+ if (event.event === "run.kill") return "killed";
400
+ if (event.event === "run.cancel") return "cancelled";
405
401
  }
406
402
  return undefined;
407
403
  }
@@ -913,13 +909,7 @@ function parseRunInboxLine(line: string): RunInboxMessage | undefined {
913
909
  }
914
910
 
915
911
  function readRunInboxMessagesFromStateDir(stateDir: string): RunInboxMessage[] {
916
- const file = runInboxFile(stateDir);
917
- if (!existsSync(file)) return [];
918
- return readFileSync(file, "utf8")
919
- .split("\n")
920
- .filter((line) => line.trim())
921
- .map(parseRunInboxLine)
922
- .filter((message): message is RunInboxMessage => Boolean(message));
912
+ return readJsonlFileResilient<RunInboxMessage>(runInboxFile(stateDir)).records;
923
913
  }
924
914
 
925
915
  function writeRunInboxMessages(