@llblab/pi-actors 0.33.0 → 0.34.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 (155) hide show
  1. package/AGENTS.md +22 -11
  2. package/BACKLOG.md +3 -34
  3. package/CHANGELOG.md +14 -0
  4. package/dist/index.js +10 -10
  5. package/dist/lib/async-runs.d.ts +23 -75
  6. package/dist/lib/async-runs.js +49 -801
  7. package/dist/lib/command-templates.d.ts +3 -3
  8. package/dist/lib/command-templates.js +3 -3
  9. package/dist/lib/config.d.ts +2 -2
  10. package/dist/lib/config.js +5 -5
  11. package/dist/lib/{output.d.ts → execution-output.d.ts} +3 -3
  12. package/dist/lib/{output.js → execution-output.js} +3 -3
  13. package/dist/lib/execution.js +1 -1
  14. package/dist/lib/{actor-inspector-tui.d.ts → inspector.d.ts} +3 -2
  15. package/dist/lib/{actor-inspector-tui.js → inspector.js} +3 -2
  16. package/dist/lib/mailbox-loop.d.ts +3 -3
  17. package/dist/lib/mailbox-loop.js +3 -3
  18. package/dist/lib/{actor-messages.d.ts → messages.d.ts} +3 -3
  19. package/dist/lib/{actor-messages.js → messages.js} +3 -3
  20. package/dist/lib/{actor-recipe-context.d.ts → recipes-context.d.ts} +3 -2
  21. package/dist/lib/{actor-recipe-context.js → recipes-context.js} +2 -1
  22. package/dist/lib/{recipe-discovery.d.ts → recipes-discovery.d.ts} +8 -8
  23. package/dist/lib/{recipe-discovery.js → recipes-discovery.js} +6 -6
  24. package/dist/lib/registry.d.ts +1 -1
  25. package/dist/lib/registry.js +22 -22
  26. package/dist/lib/{actor-rooms.d.ts → rooms.d.ts} +4 -4
  27. package/dist/lib/{actor-rooms.js → rooms.js} +3 -3
  28. package/dist/lib/runs-artifacts.d.ts +21 -0
  29. package/dist/lib/runs-artifacts.js +68 -0
  30. package/dist/lib/runs-control.d.ts +13 -0
  31. package/dist/lib/runs-control.js +59 -0
  32. package/dist/lib/runs-identity.d.ts +5 -0
  33. package/dist/lib/runs-identity.js +11 -0
  34. package/dist/lib/runs-index.d.ts +18 -0
  35. package/dist/lib/runs-index.js +119 -0
  36. package/dist/lib/runs-mailbox.d.ts +25 -0
  37. package/dist/lib/runs-mailbox.js +146 -0
  38. package/dist/lib/runs-messages.d.ts +15 -0
  39. package/dist/lib/runs-messages.js +179 -0
  40. package/dist/lib/runs-outbox.d.ts +41 -0
  41. package/dist/lib/runs-outbox.js +87 -0
  42. package/dist/lib/runs-process.d.ts +7 -0
  43. package/dist/lib/runs-process.js +36 -0
  44. package/dist/lib/runs-retention.d.ts +8 -0
  45. package/dist/lib/runs-retention.js +56 -0
  46. package/dist/lib/runs-start.d.ts +9 -0
  47. package/dist/lib/runs-start.js +67 -0
  48. package/dist/lib/runs-status.d.ts +8 -0
  49. package/dist/lib/runs-status.js +60 -0
  50. package/dist/lib/runtime.d.ts +2 -2
  51. package/dist/lib/runtime.js +5 -5
  52. package/dist/lib/schema.d.ts +9 -0
  53. package/dist/lib/schema.js +40 -0
  54. package/dist/lib/tools-access.d.ts +19 -0
  55. package/dist/lib/tools-access.js +44 -0
  56. package/dist/lib/tools-inspect.d.ts +11 -0
  57. package/dist/lib/tools-inspect.js +534 -0
  58. package/dist/lib/tools-local.d.ts +14 -0
  59. package/dist/lib/tools-local.js +156 -0
  60. package/dist/lib/tools-mailbox.d.ts +8 -0
  61. package/dist/lib/tools-mailbox.js +48 -0
  62. package/dist/lib/tools-message.d.ts +9 -0
  63. package/dist/lib/tools-message.js +428 -0
  64. package/dist/lib/tools-register.d.ts +18 -0
  65. package/dist/lib/tools-register.js +40 -0
  66. package/dist/lib/tools-response.d.ts +21 -0
  67. package/dist/lib/tools-response.js +219 -0
  68. package/dist/lib/tools-spawn.d.ts +12 -0
  69. package/dist/lib/tools-spawn.js +159 -0
  70. package/dist/lib/tools.d.ts +8 -39
  71. package/dist/lib/tools.js +11 -1490
  72. package/dist/recipes/utility-coordinator-lock-snapshot.json +1 -1
  73. package/dist/scripts/actor-worker.mjs +190 -7
  74. package/dist/scripts/async-runner.mjs +171 -8
  75. package/dist/scripts/build-dist.mjs +1 -1
  76. package/dist/scripts/conformance.mjs +3 -3
  77. package/dist/scripts/coordinator.mjs +733 -11
  78. package/dist/scripts/locker.mjs +345 -16
  79. package/dist/scripts/recipe-utils.mjs +478 -14
  80. package/dist/scripts/validate-recipe.mjs +110 -10
  81. package/dist/skills/actors/SKILL.md +1 -1
  82. package/dist/skills/swarm/SKILL.md +1 -1
  83. package/docs/recipe-library.md +1 -1
  84. package/docs/template-recipes.md +1 -1
  85. package/index.ts +11 -18
  86. package/lib/async-runs.ts +124 -1016
  87. package/lib/command-templates.ts +3 -3
  88. package/lib/config.ts +7 -7
  89. package/lib/{output.ts → execution-output.ts} +3 -3
  90. package/lib/execution.ts +5 -1
  91. package/lib/{actor-inspector-tui.ts → inspector.ts} +4 -3
  92. package/lib/mailbox-loop.ts +3 -3
  93. package/lib/{actor-messages.ts → messages.ts} +3 -3
  94. package/lib/{actor-recipe-context.ts → recipes-context.ts} +3 -2
  95. package/lib/{recipe-discovery.ts → recipes-discovery.ts} +19 -19
  96. package/lib/{recipe-usage.ts → recipes-usage.ts} +2 -2
  97. package/lib/registry.ts +37 -24
  98. package/lib/{actor-rooms.ts → rooms.ts} +4 -4
  99. package/lib/runs-artifacts.ts +96 -0
  100. package/lib/runs-control.ts +85 -0
  101. package/lib/runs-identity.ts +14 -0
  102. package/lib/runs-index.ts +156 -0
  103. package/lib/runs-mailbox.ts +208 -0
  104. package/lib/runs-messages.ts +252 -0
  105. package/lib/runs-outbox.ts +144 -0
  106. package/lib/runs-process.ts +51 -0
  107. package/lib/runs-retention.ts +77 -0
  108. package/lib/runs-start.ts +98 -0
  109. package/lib/runs-status.ts +78 -0
  110. package/lib/runtime.ts +6 -6
  111. package/lib/schema.ts +55 -0
  112. package/lib/tools-access.ts +68 -0
  113. package/lib/tools-inspect.ts +735 -0
  114. package/lib/tools-local.ts +234 -0
  115. package/lib/tools-mailbox.ts +56 -0
  116. package/lib/tools-message.ts +569 -0
  117. package/lib/tools-register.ts +64 -0
  118. package/lib/tools-response.ts +267 -0
  119. package/lib/tools-spawn.ts +240 -0
  120. package/lib/tools.ts +20 -2040
  121. package/package.json +1 -1
  122. package/recipes/utility-coordinator-lock-snapshot.json +1 -1
  123. package/scripts/actor-worker.mjs +190 -7
  124. package/scripts/async-runner.mjs +171 -8
  125. package/scripts/build-dist.mjs +1 -1
  126. package/scripts/conformance.mjs +3 -3
  127. package/scripts/coordinator.mjs +733 -11
  128. package/scripts/locker.mjs +345 -16
  129. package/scripts/recipe-utils.mjs +478 -14
  130. package/scripts/validate-recipe.mjs +110 -10
  131. package/skills/actors/SKILL.md +1 -1
  132. package/skills/swarm/SKILL.md +1 -1
  133. package/dist/lib/actor-worker.d.ts +0 -16
  134. package/dist/lib/actor-worker.js +0 -134
  135. package/dist/lib/async-runner.d.ts +0 -5
  136. package/dist/lib/async-runner.js +0 -134
  137. package/dist/lib/coordinator.d.ts +0 -5
  138. package/dist/lib/coordinator.js +0 -660
  139. package/dist/lib/locker.d.ts +0 -5
  140. package/dist/lib/locker.js +0 -310
  141. package/dist/lib/recipe-utils.d.ts +0 -5
  142. package/dist/lib/recipe-utils.js +0 -385
  143. package/dist/lib/validate-recipe.d.ts +0 -6
  144. package/dist/lib/validate-recipe.js +0 -104
  145. package/lib/actor-worker.ts +0 -174
  146. package/lib/async-runner.ts +0 -173
  147. package/lib/coordinator.ts +0 -742
  148. package/lib/locker.ts +0 -340
  149. package/lib/recipe-utils.ts +0 -486
  150. package/lib/validate-recipe.ts +0 -110
  151. /package/dist/lib/{recipe-references.d.ts → recipes-references.d.ts} +0 -0
  152. /package/dist/lib/{recipe-references.js → recipes-references.js} +0 -0
  153. /package/dist/lib/{recipe-usage.d.ts → recipes-usage.d.ts} +0 -0
  154. /package/dist/lib/{recipe-usage.js → recipes-usage.js} +0 -0
  155. /package/lib/{recipe-references.ts → recipes-references.ts} +0 -0
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Async run process control primitives.
3
+ * Owns platform signal planning, owned-process signalling, and terminal control markers.
4
+ */
5
+
6
+ import { spawnSync } from "node:child_process";
7
+ import { join } from "node:path";
8
+
9
+ import { writeJsonAtomic } from "./file-state.ts";
10
+
11
+ export interface RunProcessSignalPlan {
12
+ args?: string[];
13
+ command?: string;
14
+ signalTarget: "processGroup" | "process" | "processTree";
15
+ }
16
+
17
+ export function getRunProcessSignalPlan(
18
+ pid: number,
19
+ signal: NodeJS.Signals,
20
+ runtimePlatform: NodeJS.Platform = process.platform,
21
+ ): RunProcessSignalPlan {
22
+ if (runtimePlatform === "win32") {
23
+ return {
24
+ args: [
25
+ "/PID",
26
+ String(pid),
27
+ "/T",
28
+ ...(signal === "SIGKILL" ? ["/F"] : []),
29
+ ],
30
+ command: "taskkill",
31
+ signalTarget: "processTree",
32
+ };
33
+ }
34
+ return { signalTarget: "processGroup" };
35
+ }
36
+
37
+ export function signalOwnedRunProcess(
38
+ pid: number,
39
+ signal: NodeJS.Signals,
40
+ ): RunProcessSignalPlan {
41
+ const plan = getRunProcessSignalPlan(pid, signal);
42
+ if (plan.command && plan.args) {
43
+ const result = spawnSync(plan.command, plan.args, { encoding: "utf8" });
44
+ if (result.status !== 0) {
45
+ throw new Error(
46
+ result.stderr?.trim() ||
47
+ result.stdout?.trim() ||
48
+ `${plan.command} failed`,
49
+ );
50
+ }
51
+ return plan;
52
+ }
53
+ try {
54
+ process.kill(-pid, signal);
55
+ return { signalTarget: "processGroup" };
56
+ } catch {
57
+ process.kill(pid, signal);
58
+ return { signalTarget: "process" };
59
+ }
60
+ }
61
+
62
+ export function markTerminalHandled(
63
+ stateDir: string,
64
+ details: Record<string, unknown>,
65
+ ): void {
66
+ writeJsonAtomic(join(stateDir, "terminal-handled.json"), {
67
+ ...details,
68
+ ts: new Date().toISOString(),
69
+ });
70
+ }
71
+
72
+ export function buildTerminalProgress(
73
+ existing: Record<string, unknown> | undefined,
74
+ phase: "cancelled" | "killed",
75
+ ): Record<string, unknown> {
76
+ const progress = existing ?? {};
77
+ const { activeSubagents: _activeSubagents, ...rest } = progress;
78
+ return {
79
+ ...rest,
80
+ completed: typeof progress.completed === "number" ? progress.completed : 0,
81
+ failures: Array.isArray(progress.failures) ? progress.failures : [],
82
+ phase,
83
+ updatedAt: new Date().toISOString(),
84
+ };
85
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Async run identity helpers.
3
+ * Owns run id normalization shared by run lifecycle and retention paths.
4
+ */
5
+
6
+ export function safeRunId(value: string | undefined): string {
7
+ const run = (value || `run-${Date.now()}`).trim();
8
+ if (!/^[A-Za-z0-9_.-]+$/.test(run)) {
9
+ throw new Error(
10
+ "Run id may contain only letters, numbers, dot, underscore, and dash.",
11
+ );
12
+ }
13
+ return run;
14
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Async run state index.
3
+ * Owns recursive run-state discovery, index rebuild/read, and status filtering.
4
+ */
5
+
6
+ import { existsSync, mkdirSync, readdirSync } from "node:fs";
7
+ import { join, resolve } from "node:path";
8
+
9
+ import { writeJsonAtomic } from "./file-state.ts";
10
+
11
+ export interface RunStateIndexEntry {
12
+ ownerId?: string;
13
+ recipe?: string;
14
+ run: string;
15
+ state_dir: string;
16
+ status: string;
17
+ tool?: string;
18
+ updated_at?: string;
19
+ }
20
+
21
+ export type RunStatusReader = (runOrDir: string) => Record<string, unknown>;
22
+
23
+ function matchesStatusFilter(
24
+ status: unknown,
25
+ filter: string | undefined,
26
+ ): boolean {
27
+ if (!filter || filter === "all") return true;
28
+ if (filter === "active") return status === "running";
29
+ if (filter === "terminal") return status !== "running";
30
+ return status === filter;
31
+ }
32
+
33
+ export function listRunStateDirs(
34
+ stateRoot: string,
35
+ depth = 0,
36
+ seen = new Set<string>(),
37
+ ): string[] {
38
+ if (!existsSync(stateRoot) || seen.has(resolve(stateRoot))) return [];
39
+ seen.add(resolve(stateRoot));
40
+ let entries: import("node:fs").Dirent[];
41
+ try {
42
+ entries = readdirSync(stateRoot, { withFileTypes: true });
43
+ } catch {
44
+ return [];
45
+ }
46
+ const result: string[] = [];
47
+ for (const entry of entries) {
48
+ if (!entry.isDirectory()) continue;
49
+ const child = join(stateRoot, entry.name);
50
+ if (existsSync(join(child, "run.json"))) result.push(child);
51
+ if (depth + 1 < 8) result.push(...listRunStateDirs(child, depth + 1, seen));
52
+ }
53
+ return result;
54
+ }
55
+
56
+ function runIndexPath(stateRoot: string): string {
57
+ return join(stateRoot, "index.json");
58
+ }
59
+
60
+ function indexEntryFromStatus(
61
+ status: Record<string, unknown>,
62
+ ): RunStateIndexEntry {
63
+ const progress =
64
+ status.progress && typeof status.progress === "object"
65
+ ? (status.progress as Record<string, unknown>)
66
+ : {};
67
+ return {
68
+ ...(typeof status.ownerId === "string" ? { ownerId: status.ownerId } : {}),
69
+ ...(typeof status.recipe === "string" ? { recipe: status.recipe } : {}),
70
+ run: String(status.run),
71
+ state_dir: String(status.state_dir),
72
+ status: String(status.status),
73
+ ...(typeof status.tool === "string" ? { tool: status.tool } : {}),
74
+ updated_at:
75
+ typeof progress.updatedAt === "string"
76
+ ? progress.updatedAt
77
+ : typeof status.createdAt === "string"
78
+ ? status.createdAt
79
+ : new Date(0).toISOString(),
80
+ };
81
+ }
82
+
83
+ export function rebuildRunStateIndex(
84
+ stateRoot: string,
85
+ getRunStatus: RunStatusReader,
86
+ ): RunStateIndexEntry[] {
87
+ mkdirSync(stateRoot, { recursive: true });
88
+ const entries = listRunStateDirs(stateRoot)
89
+ .flatMap((stateDir) => {
90
+ try {
91
+ return [indexEntryFromStatus(getRunStatus(stateDir))];
92
+ } catch {
93
+ return [];
94
+ }
95
+ })
96
+ .sort((a, b) => (b.updated_at ?? "").localeCompare(a.updated_at ?? ""));
97
+ writeJsonAtomic(runIndexPath(stateRoot), {
98
+ entries,
99
+ rebuilt_at: new Date().toISOString(),
100
+ });
101
+ return entries;
102
+ }
103
+
104
+ export function readRunStateIndex(
105
+ stateRoot: string,
106
+ readJson: (path: string) => Record<string, unknown> | undefined,
107
+ ): RunStateIndexEntry[] | undefined {
108
+ const index = readJson(runIndexPath(stateRoot));
109
+ if (!index || typeof index !== "object") return undefined;
110
+ const entries = (index as Record<string, unknown>).entries;
111
+ if (!Array.isArray(entries)) return undefined;
112
+ const valid = entries.filter((entry): entry is RunStateIndexEntry => {
113
+ const record =
114
+ entry && typeof entry === "object"
115
+ ? (entry as Record<string, unknown>)
116
+ : {};
117
+ return (
118
+ typeof record.run === "string" &&
119
+ typeof record.state_dir === "string" &&
120
+ typeof record.status === "string"
121
+ );
122
+ });
123
+ if (valid.some((entry) => !existsSync(join(entry.state_dir, "run.json"))))
124
+ return undefined;
125
+ const indexedDirs = new Set(valid.map((entry) => resolve(entry.state_dir)));
126
+ const stateDirs = listRunStateDirs(stateRoot).map((stateDir) =>
127
+ resolve(stateDir),
128
+ );
129
+ if (stateDirs.some((stateDir) => !indexedDirs.has(stateDir)))
130
+ return undefined;
131
+ return valid;
132
+ }
133
+
134
+ function compactIndexEntry(entry: RunStateIndexEntry): Record<string, unknown> {
135
+ return {
136
+ run: entry.run,
137
+ state_dir: entry.state_dir,
138
+ status: entry.status,
139
+ ...(entry.tool ? { tool: entry.tool } : {}),
140
+ ...(entry.recipe ? { recipe: entry.recipe } : {}),
141
+ };
142
+ }
143
+
144
+ export function listRuns(
145
+ stateRoot: string,
146
+ getRunStatus: RunStatusReader,
147
+ readJson: (path: string) => Record<string, unknown> | undefined,
148
+ statusFilter?: string,
149
+ ): Array<Record<string, unknown>> {
150
+ if (!existsSync(stateRoot)) return [];
151
+ const indexed = readRunStateIndex(stateRoot, readJson);
152
+ const entries = indexed ?? rebuildRunStateIndex(stateRoot, getRunStatus);
153
+ return entries
154
+ .filter((entry) => matchesStatusFilter(entry.status, statusFilter))
155
+ .map(compactIndexEntry);
156
+ }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Async run mailbox state.
3
+ * Owns durable run inbox records, claim locks, and handler status transitions.
4
+ */
5
+
6
+ import { randomUUID } from "node:crypto";
7
+ import { mkdirSync, rmSync, statSync, writeFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+
10
+ import { readJsonlFileResilient } from "./state-readers.ts";
11
+
12
+ const RUN_INBOX_LOCK_TIMEOUT_MS = 5000;
13
+ const STALE_INBOX_LOCK_MAX_AGE_MS = 5 * 60 * 1000;
14
+
15
+ export type RunInboxStatus =
16
+ | "queued"
17
+ | "sent"
18
+ | "claimed"
19
+ | "handled"
20
+ | "failed";
21
+
22
+ export type RunInboxMessage = Record<string, unknown> & {
23
+ id?: string;
24
+ status?: RunInboxStatus | string;
25
+ };
26
+
27
+ export interface ProcessRunInboxResult {
28
+ claimed: number;
29
+ failed: number;
30
+ handled: number;
31
+ }
32
+
33
+ export function runInboxFile(stateDir: string): string {
34
+ return join(stateDir, "inbox.jsonl");
35
+ }
36
+
37
+ export function parseRunInboxLine(line: string): RunInboxMessage | undefined {
38
+ try {
39
+ return JSON.parse(line) as RunInboxMessage;
40
+ } catch {
41
+ return undefined;
42
+ }
43
+ }
44
+
45
+ export function readRunInboxMessagesFromStateDir(
46
+ stateDir: string,
47
+ ): RunInboxMessage[] {
48
+ return readJsonlFileResilient<RunInboxMessage>(runInboxFile(stateDir))
49
+ .records;
50
+ }
51
+
52
+ function sleepSync(ms: number): void {
53
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
54
+ }
55
+
56
+ function acquireRunInboxLock(stateDir: string): () => void {
57
+ const lockDir = join(stateDir, ".inbox.lock");
58
+ const started = Date.now();
59
+ while (true) {
60
+ try {
61
+ mkdirSync(lockDir, { recursive: false });
62
+ writeFileSync(
63
+ join(lockDir, "owner.json"),
64
+ `${JSON.stringify({ pid: process.pid, created_at: new Date().toISOString() })}\n`,
65
+ "utf8",
66
+ );
67
+ return () => rmSync(lockDir, { recursive: true, force: true });
68
+ } catch (error) {
69
+ try {
70
+ const stat = statSync(lockDir);
71
+ if (Date.now() - stat.mtimeMs > STALE_INBOX_LOCK_MAX_AGE_MS) {
72
+ rmSync(lockDir, { recursive: true, force: true });
73
+ continue;
74
+ }
75
+ } catch {
76
+ continue;
77
+ }
78
+ if (Date.now() - started > RUN_INBOX_LOCK_TIMEOUT_MS) {
79
+ throw new Error("Run inbox lock timed out.", { cause: error });
80
+ }
81
+ sleepSync(10);
82
+ }
83
+ }
84
+ }
85
+
86
+ function writeRunInboxMessages(
87
+ stateDir: string,
88
+ messages: RunInboxMessage[],
89
+ ): void {
90
+ writeFileSync(
91
+ runInboxFile(stateDir),
92
+ messages.length
93
+ ? `${messages.map((message) => JSON.stringify(message)).join("\n")}\n`
94
+ : "",
95
+ "utf8",
96
+ );
97
+ }
98
+
99
+ export function updateRunInboxMessageStatusInStateDir(
100
+ stateDir: string,
101
+ id: string,
102
+ nextStatus: RunInboxStatus,
103
+ metadata: Record<string, unknown> = {},
104
+ ): boolean {
105
+ const releaseLock = acquireRunInboxLock(stateDir);
106
+ try {
107
+ const messages = readRunInboxMessagesFromStateDir(stateDir);
108
+ const timestampKey = `${nextStatus}_at`;
109
+ let changed = false;
110
+ const updated = messages.map((message) => {
111
+ if (message.id !== id) return message;
112
+ changed = true;
113
+ return {
114
+ ...message,
115
+ ...metadata,
116
+ [timestampKey]: new Date().toISOString(),
117
+ status: nextStatus,
118
+ };
119
+ });
120
+ if (changed) writeRunInboxMessages(stateDir, updated);
121
+ return changed;
122
+ } finally {
123
+ releaseLock();
124
+ }
125
+ }
126
+
127
+ export function claimRunInboxMessageInStateDir(
128
+ stateDir: string,
129
+ owner = "runtime",
130
+ statuses: string[] = ["queued"],
131
+ ): RunInboxMessage | undefined {
132
+ const releaseLock = acquireRunInboxLock(stateDir);
133
+ try {
134
+ const messages = readRunInboxMessagesFromStateDir(stateDir);
135
+ const index = messages.findIndex((message) =>
136
+ statuses.includes(String(message.status ?? "queued")),
137
+ );
138
+ if (index < 0) return undefined;
139
+ const claimed = {
140
+ ...messages[index],
141
+ claimed_at: new Date().toISOString(),
142
+ claimed_by: owner,
143
+ id:
144
+ typeof messages[index].id === "string"
145
+ ? messages[index].id
146
+ : randomUUID(),
147
+ status: "claimed",
148
+ } satisfies RunInboxMessage;
149
+ messages[index] = claimed;
150
+ writeRunInboxMessages(stateDir, messages);
151
+ return claimed;
152
+ } finally {
153
+ releaseLock();
154
+ }
155
+ }
156
+
157
+ export function appendRunInboxMessageInStateDir(
158
+ stateDir: string,
159
+ record: Record<string, unknown>,
160
+ ): string {
161
+ const id = randomUUID();
162
+ const ts = new Date().toISOString();
163
+ const releaseLock = acquireRunInboxLock(stateDir);
164
+ try {
165
+ writeFileSync(
166
+ runInboxFile(stateDir),
167
+ `${JSON.stringify({ ...record, id, queued_at: ts, received_at: ts, status: "queued" })}\n`,
168
+ { flag: "a" },
169
+ );
170
+ } finally {
171
+ releaseLock();
172
+ }
173
+ return id;
174
+ }
175
+
176
+ export async function processRunInboxMessagesInStateDir(
177
+ stateDir: string,
178
+ handler: (message: RunInboxMessage) => Promise<void> | void,
179
+ options: { limit?: number; owner?: string; statuses?: string[] } = {},
180
+ ): Promise<ProcessRunInboxResult> {
181
+ const result: ProcessRunInboxResult = { claimed: 0, failed: 0, handled: 0 };
182
+ const limit = Math.max(1, Number(options.limit ?? 1));
183
+ const owner = options.owner ?? "runtime";
184
+ for (let index = 0; index < limit; index += 1) {
185
+ const message = claimRunInboxMessageInStateDir(
186
+ stateDir,
187
+ owner,
188
+ options.statuses,
189
+ );
190
+ if (!message?.id) break;
191
+ result.claimed += 1;
192
+ try {
193
+ await handler(message);
194
+ if (updateRunInboxMessageStatusInStateDir(stateDir, message.id, "handled")) {
195
+ result.handled += 1;
196
+ }
197
+ } catch (error) {
198
+ if (
199
+ updateRunInboxMessageStatusInStateDir(stateDir, message.id, "failed", {
200
+ error: error instanceof Error ? error.message : String(error),
201
+ })
202
+ ) {
203
+ result.failed += 1;
204
+ }
205
+ }
206
+ }
207
+ return result;
208
+ }
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Async run message delivery.
3
+ * Owns run control endpoint resolution, inbox enqueueing, wake notification,
4
+ * transport writes, and delivery receipts.
5
+ */
6
+
7
+ import {
8
+ closeSync,
9
+ constants,
10
+ existsSync,
11
+ openSync,
12
+ statSync,
13
+ writeFileSync,
14
+ writeSync,
15
+ } from "node:fs";
16
+ import { createConnection } from "node:net";
17
+ import { join } from "node:path";
18
+
19
+ import { markTerminalHandled } from "./runs-control.ts";
20
+ import {
21
+ appendRunInboxMessageInStateDir,
22
+ updateRunInboxMessageStatusInStateDir,
23
+ } from "./runs-mailbox.ts";
24
+ import { notifyRuntimeWake } from "./runtime-notifier.ts";
25
+
26
+ export interface RunControlEndpoint {
27
+ path: string;
28
+ type: "fifo" | "mailbox" | "named-pipe";
29
+ }
30
+
31
+ export interface SendRunMessageOptions {
32
+ namedPipeSend?: (path: string, payload: string) => Promise<number>;
33
+ platform?: NodeJS.Platform;
34
+ }
35
+
36
+ export function getRunControlEndpoint(
37
+ status: Record<string, unknown>,
38
+ stateDir: string,
39
+ ): RunControlEndpoint {
40
+ const control = status.control;
41
+ if (control && typeof control === "object" && !Array.isArray(control)) {
42
+ const record = control as Record<string, unknown>;
43
+ if (
44
+ (record.type === "fifo" ||
45
+ record.type === "mailbox" ||
46
+ record.type === "named-pipe") &&
47
+ typeof record.path === "string" &&
48
+ record.path.trim()
49
+ ) {
50
+ return { path: record.path, type: record.type };
51
+ }
52
+ }
53
+ return { path: join(stateDir, "control.fifo"), type: "fifo" };
54
+ }
55
+
56
+ function appendRunInboxMessage(stateDir: string, message: string): string {
57
+ let record: Record<string, unknown>;
58
+ try {
59
+ const parsed = JSON.parse(message) as unknown;
60
+ record =
61
+ parsed && typeof parsed === "object" && !Array.isArray(parsed)
62
+ ? (parsed as Record<string, unknown>)
63
+ : { body: parsed, type: "run.message" };
64
+ } catch {
65
+ record = { body: message, type: "run.message" };
66
+ }
67
+ return appendRunInboxMessageInStateDir(stateDir, record);
68
+ }
69
+
70
+ function writeRunMessageReceipt(
71
+ stateDir: string,
72
+ message: string,
73
+ bytes: number,
74
+ inboxId: string,
75
+ ): void {
76
+ const trimmedMessage = message.trim().toLowerCase();
77
+ const terminalMessage = ["stop", "cancel", "quit", "exit"].includes(
78
+ trimmedMessage,
79
+ );
80
+ const ts = new Date().toISOString();
81
+ writeFileSync(
82
+ join(stateDir, "events.jsonl"),
83
+ `${JSON.stringify({ bytes, event: "run.message", inbox_id: inboxId, terminal: terminalMessage || undefined, ts })}\n`,
84
+ { flag: "a" },
85
+ );
86
+ updateRunInboxMessageStatusInStateDir(stateDir, inboxId, "sent", { bytes });
87
+ if (terminalMessage) {
88
+ markTerminalHandled(stateDir, {
89
+ event: "run.message",
90
+ message: trimmedMessage,
91
+ });
92
+ }
93
+ }
94
+
95
+ function sendRunMessageToFifo(
96
+ endpoint: RunControlEndpoint,
97
+ payload: string,
98
+ ): number {
99
+ if (!existsSync(endpoint.path))
100
+ throw new Error(`Run control FIFO not found: ${endpoint.path}`);
101
+ const stat = statSync(endpoint.path);
102
+ if ((stat.mode & constants.S_IFMT) !== constants.S_IFIFO) {
103
+ throw new Error(`Run control endpoint is not a FIFO: ${endpoint.path}`);
104
+ }
105
+ let fd: number | undefined;
106
+ try {
107
+ fd = openSync(endpoint.path, constants.O_WRONLY | constants.O_NONBLOCK);
108
+ return writeSync(fd, payload);
109
+ } finally {
110
+ if (fd !== undefined) closeSync(fd);
111
+ }
112
+ }
113
+
114
+ function notifyRunMessageWake(
115
+ stateDir: string,
116
+ run: string,
117
+ bytes: number,
118
+ endpoint: RunControlEndpoint,
119
+ inboxId: string,
120
+ ): Record<string, unknown> | undefined {
121
+ try {
122
+ const event = notifyRuntimeWake(stateDir, {
123
+ actor: `run:${run}`,
124
+ metadata: { bytes, control_type: endpoint.type, inbox_id: inboxId },
125
+ reason: "run.message",
126
+ });
127
+ return { wake: "wake.jsonl", wake_id: event.id };
128
+ } catch {
129
+ return undefined;
130
+ }
131
+ }
132
+
133
+ function sendRunMessageToNamedPipe(
134
+ endpoint: RunControlEndpoint,
135
+ payload: string,
136
+ send?: (path: string, payload: string) => Promise<number>,
137
+ ): Promise<number> {
138
+ if (send) return send(endpoint.path, payload);
139
+ return new Promise((resolve, reject) => {
140
+ const socket = createConnection(endpoint.path);
141
+ let settled = false;
142
+ const timeout = setTimeout(() => {
143
+ if (settled) return;
144
+ settled = true;
145
+ socket.destroy();
146
+ reject(new Error("named pipe connection timed out"));
147
+ }, 5000);
148
+ const finish = (error?: Error): void => {
149
+ if (settled) return;
150
+ settled = true;
151
+ clearTimeout(timeout);
152
+ if (error) reject(error);
153
+ else resolve(Buffer.byteLength(payload));
154
+ };
155
+ socket.on("error", finish);
156
+ socket.on("connect", () => {
157
+ socket.end(payload, () => finish());
158
+ });
159
+ });
160
+ }
161
+
162
+ export async function deliverRunMessage(
163
+ status: Record<string, unknown>,
164
+ run: string,
165
+ stateDir: string,
166
+ message: string,
167
+ options: SendRunMessageOptions = {},
168
+ ): Promise<Record<string, unknown>> {
169
+ const endpoint = getRunControlEndpoint(status, stateDir);
170
+ const payload = message.endsWith("\n") ? message : `${message}\n`;
171
+ const payloadBytes = Buffer.byteLength(payload);
172
+ const inboxId = appendRunInboxMessage(stateDir, message);
173
+ const wake = notifyRunMessageWake(
174
+ stateDir,
175
+ run,
176
+ payloadBytes,
177
+ endpoint,
178
+ inboxId,
179
+ );
180
+ const runtimePlatform = options.platform ?? process.platform;
181
+ if (endpoint.type === "mailbox") {
182
+ return {
183
+ ...(wake ?? {}),
184
+ bytes: payloadBytes,
185
+ control: "inbox.jsonl",
186
+ control_path: endpoint.path,
187
+ control_type: endpoint.type,
188
+ inbox_id: inboxId,
189
+ queued: true,
190
+ run,
191
+ sent: true,
192
+ state_dir: stateDir,
193
+ };
194
+ }
195
+ try {
196
+ if (endpoint.type === "fifo") {
197
+ if (runtimePlatform === "win32") {
198
+ throw new Error(
199
+ "run actor messages on native Windows require a named-pipe control endpoint; this recipe still exposes Unix FIFO control.",
200
+ );
201
+ }
202
+ const bytes = sendRunMessageToFifo(endpoint, payload);
203
+ writeRunMessageReceipt(stateDir, message, bytes, inboxId);
204
+ return {
205
+ ...(wake ?? {}),
206
+ bytes,
207
+ control: "control.fifo",
208
+ control_path: endpoint.path,
209
+ control_type: endpoint.type,
210
+ inbox_id: inboxId,
211
+ run,
212
+ sent: true,
213
+ state_dir: stateDir,
214
+ };
215
+ }
216
+ const bytes = await sendRunMessageToNamedPipe(
217
+ endpoint,
218
+ payload,
219
+ options.namedPipeSend,
220
+ );
221
+ writeRunMessageReceipt(stateDir, message, bytes, inboxId);
222
+ return {
223
+ ...(wake ?? {}),
224
+ bytes,
225
+ control: endpoint.path,
226
+ control_path: endpoint.path,
227
+ control_type: endpoint.type,
228
+ inbox_id: inboxId,
229
+ run,
230
+ sent: true,
231
+ state_dir: stateDir,
232
+ };
233
+ } catch (error) {
234
+ const deliveryError =
235
+ error instanceof Error ? error.message : String(error);
236
+ throw Object.assign(
237
+ new Error(
238
+ `Run control endpoint is not ready: ${endpoint.path}: ${deliveryError}`,
239
+ ),
240
+ {
241
+ control_path: endpoint.path,
242
+ control_type: endpoint.type,
243
+ delivery_error: deliveryError,
244
+ inbox_id: inboxId,
245
+ queued: true,
246
+ run,
247
+ sent: false,
248
+ state_dir: stateDir,
249
+ },
250
+ );
251
+ }
252
+ }