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