@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
package/lib/async-runs.ts CHANGED
@@ -1,29 +1,19 @@
1
1
  /**
2
- * Command-template async run primitives
3
- * Zones: async runtime, lifecycle, state files
4
- * Owns detached run state, observation, log tailing, listing, and cancellation safety
2
+ * Command-template async run lifecycle facade.
3
+ * Owns launch, state observation, listing, message/control facade methods, and retention while runs-* subdomains own narrower run internals.
5
4
  */
6
5
 
7
- import { spawn, spawnSync } from "node:child_process";
8
- import { createHash, randomUUID } from "node:crypto";
6
+ import { spawn } from "node:child_process";
9
7
  import {
10
8
  closeSync,
11
- constants,
12
9
  existsSync,
13
10
  mkdirSync,
14
11
  openSync,
15
- readdirSync,
16
12
  readFileSync,
17
- cpSync,
18
- readlinkSync,
19
- renameSync,
20
13
  rmSync,
21
14
  statSync,
22
15
  writeFileSync,
23
- writeSync,
24
16
  } from "node:fs";
25
- import { createConnection } from "node:net";
26
- import { platform } from "node:os";
27
17
  import { basename, dirname, extname, join, resolve } from "node:path";
28
18
  import { fileURLToPath } from "node:url";
29
19
 
@@ -31,16 +21,59 @@ import type {
31
21
  CommandTemplateFailureScope,
32
22
  CommandTemplateValue,
33
23
  } from "./command-templates.ts";
34
- import { substituteCommandTemplateToken } from "./command-templates.ts";
35
24
  import { writeJsonAtomic } from "./file-state.ts";
36
- import { readJsonFileResilient, readJsonlFileResilient } from "./state-readers.ts";
37
25
  import * as Paths from "./paths.ts";
38
- import * as RecipeReferences from "./recipe-references.ts";
39
- import * as RecipeUsage from "./recipe-usage.ts";
40
- import { notifyRuntimeWake } from "./runtime-notifier.ts";
26
+ import * as RecipesReferences from "./recipes-references.ts";
27
+ import * as RecipesUsage from "./recipes-usage.ts";
28
+ import {
29
+ resolveArtifactManifest,
30
+ resolveArtifactPaths,
31
+ type RunArtifactDeclaration,
32
+ type RunArtifactManifestEntry,
33
+ } from "./runs-artifacts.ts";
34
+ import { safeRunId } from "./runs-identity.ts";
35
+ import {
36
+ buildTerminalProgress,
37
+ getRunProcessSignalPlan,
38
+ markTerminalHandled,
39
+ signalOwnedRunProcess,
40
+ type RunProcessSignalPlan,
41
+ } from "./runs-control.ts";
42
+ import {
43
+ buildRunOutboxEventPayload,
44
+ parseRunOutboxEventLine,
45
+ type RunOutboxEvent,
46
+ } from "./runs-outbox.ts";
47
+ import { archiveTerminalRun, pruneTerminalRun } from "./runs-retention.ts";
48
+ import {
49
+ isAlive,
50
+ isWithinRunnerIdentityGrace,
51
+ pidMatchesRun,
52
+ } from "./runs-process.ts";
53
+ import * as RunsStart from "./runs-start.ts";
54
+ import * as RunsIndex from "./runs-index.ts";
55
+ import {
56
+ claimRunInboxMessageInStateDir,
57
+ parseRunInboxLine,
58
+ processRunInboxMessagesInStateDir,
59
+ readRunInboxMessagesFromStateDir,
60
+ runInboxFile,
61
+ type ProcessRunInboxResult,
62
+ type RunInboxMessage,
63
+ type RunInboxStatus,
64
+ updateRunInboxMessageStatusInStateDir,
65
+ } from "./runs-mailbox.ts";
66
+ import {
67
+ deliverRunMessage,
68
+ type SendRunMessageOptions,
69
+ } from "./runs-messages.ts";
70
+ import {
71
+ buildRunStatus,
72
+ tailFile,
73
+ tailLines,
74
+ } from "./runs-status.ts";
75
+ import { readJsonFileResilient } from "./state-readers.ts";
41
76
 
42
- const START_LOCK_MAX_AGE_MS = 5 * 60 * 1000;
43
- const RUN_INBOX_LOCK_TIMEOUT_MS = 5000;
44
77
  const RUNNER_IDENTITY_GRACE_MS = 5000;
45
78
 
46
79
  export type AsyncRunLaunchSource = "spawn" | "tool";
@@ -70,7 +103,7 @@ export interface AsyncRunStartParams {
70
103
  delay?: number | string;
71
104
  output?: string;
72
105
  artifacts?: Record<string, RunArtifactDeclaration>;
73
- mailbox?: RecipeReferences.TemplateRecipeMailbox;
106
+ mailbox?: RecipesReferences.TemplateRecipeMailbox;
74
107
  retire_when?: "children_terminal";
75
108
  retry?: number | string;
76
109
  failure?: CommandTemplateFailureScope;
@@ -88,27 +121,6 @@ export type AsyncRunStatus =
88
121
  | "exited"
89
122
  | "cancelled"
90
123
  | "killed";
91
- export type RunOutboxDelivery = "log" | "notify" | "followup";
92
- export type RunOutboxLevel = "info" | "warning" | "error";
93
-
94
- export interface RunOutboxEvent {
95
- body?: unknown;
96
- correlation_id?: string;
97
- data?: unknown;
98
- delivery: RunOutboxDelivery;
99
- event: string;
100
- from?: string;
101
- id: string;
102
- level: RunOutboxLevel;
103
- metadata?: Record<string, unknown>;
104
- reply_to?: string;
105
- run: string;
106
- state_dir: string;
107
- summary: string;
108
- to?: string;
109
- ts: string;
110
- type?: string;
111
- }
112
124
 
113
125
  export interface AsyncRunMeta {
114
126
  argv: string[];
@@ -127,8 +139,8 @@ export interface AsyncRunMeta {
127
139
  values: Record<string, unknown>;
128
140
  artifacts?: Record<string, RunArtifactDeclaration>;
129
141
  control?: AsyncRunControlEndpoint;
130
- mailbox?: RecipeReferences.TemplateRecipeMailbox;
131
- recipe_context_records?: RecipeReferences.TemplateRecipeContextRecord[];
142
+ mailbox?: RecipesReferences.TemplateRecipeMailbox;
143
+ recipe_context_records?: RecipesReferences.TemplateRecipeContextRecord[];
132
144
  retire_when?: "children_terminal";
133
145
  }
134
146
 
@@ -155,101 +167,13 @@ function asyncRunnerArgv(stateDir: string): string[] {
155
167
  : ["--experimental-strip-types", RUNNER_PATH, stateDir];
156
168
  }
157
169
 
158
- function safeRunId(value: string | undefined): string {
159
- const run = (value || `run-${Date.now()}`).trim();
160
- if (!/^[A-Za-z0-9_.-]+$/.test(run))
161
- throw new Error(
162
- "Run id may contain only letters, numbers, dot, underscore, and dash.",
163
- );
164
- return run;
165
- }
166
-
167
- export type RunArtifactDeclaration =
168
- | string
169
- | { path: string; kind?: string; media_type?: string; required?: boolean };
170
+ export { safeRunId } from "./runs-identity.ts";
170
171
 
171
- export interface RunArtifactManifestEntry {
172
- exists: boolean;
173
- kind?: string;
174
- media_type?: string;
175
- path: string;
176
- required?: boolean;
177
- sha256?: string;
178
- size?: number;
179
- }
180
-
181
- function resolveArtifactPaths(
182
- artifacts: Record<string, RunArtifactDeclaration> | undefined,
183
- values: Record<string, unknown>,
184
- ): Record<string, RunArtifactDeclaration> | undefined {
185
- if (!artifacts) return undefined;
186
- const resolved: Record<string, RunArtifactDeclaration> = {};
187
- for (const [key, value] of Object.entries(artifacts)) {
188
- if (!key.trim()) continue;
189
- if (typeof value === "string") {
190
- resolved[key] = substituteCommandTemplateToken(
191
- value,
192
- values,
193
- `recipe artifacts.${key}`,
194
- );
195
- } else if (
196
- value &&
197
- typeof value === "object" &&
198
- typeof value.path === "string"
199
- ) {
200
- resolved[key] = {
201
- ...value,
202
- path: substituteCommandTemplateToken(
203
- value.path,
204
- values,
205
- `recipe artifacts.${key}.path`,
206
- ),
207
- };
208
- }
209
- }
210
- return Object.keys(resolved).length > 0 ? resolved : undefined;
211
- }
212
-
213
- export function resolveArtifactManifest(
214
- artifacts: Record<string, RunArtifactDeclaration> | undefined,
215
- ): Record<string, RunArtifactManifestEntry> | undefined {
216
- if (!artifacts) return undefined;
217
- const manifest: Record<string, RunArtifactManifestEntry> = {};
218
- for (const [name, artifact] of Object.entries(artifacts)) {
219
- const declaration =
220
- typeof artifact === "string" ? { path: artifact } : artifact;
221
- if (!declaration?.path) continue;
222
- try {
223
- const content = readFileSync(declaration.path);
224
- manifest[name] = {
225
- exists: true,
226
- ...(declaration.kind ? { kind: declaration.kind } : {}),
227
- ...(declaration.media_type
228
- ? { media_type: declaration.media_type }
229
- : {}),
230
- path: declaration.path,
231
- ...(declaration.required !== undefined
232
- ? { required: declaration.required }
233
- : {}),
234
- sha256: createHash("sha256").update(content).digest("hex"),
235
- size: content.byteLength,
236
- };
237
- } catch {
238
- manifest[name] = {
239
- exists: false,
240
- ...(declaration.kind ? { kind: declaration.kind } : {}),
241
- ...(declaration.media_type
242
- ? { media_type: declaration.media_type }
243
- : {}),
244
- path: declaration.path,
245
- ...(declaration.required !== undefined
246
- ? { required: declaration.required }
247
- : {}),
248
- };
249
- }
250
- }
251
- return Object.keys(manifest).length > 0 ? manifest : undefined;
252
- }
172
+ export { resolveArtifactManifest } from "./runs-artifacts.ts";
173
+ export type {
174
+ RunArtifactDeclaration,
175
+ RunArtifactManifestEntry,
176
+ } from "./runs-artifacts.ts";
253
177
 
254
178
  function resolveRunTemplate(params: AsyncRunStartParams): {
255
179
  template: CommandTemplateValue;
@@ -284,20 +208,13 @@ function resolveStateDir(params: AsyncRunStartParams, run: string): string {
284
208
  }
285
209
 
286
210
  function assertNoActiveRunState(stateDir: string): void {
287
- const meta = readJson(join(stateDir, "run.json"));
288
- if (!meta) return;
289
- const pid = Number(meta.pid || 0);
290
- const cwd = String(meta.cwd ?? "");
291
- if (!pid || !isAlive(pid) || !pidMatchesRun(pid, cwd, stateDir)) return;
292
- throw new Error(
293
- `Run state already has an active owned process: ${String(meta.run ?? stateDir)}. Stop it before reusing the same run_id or state_dir.`,
294
- );
211
+ RunsStart.assertNoActiveRunState(stateDir, readJson, RUNNER_PATH);
295
212
  }
296
213
 
297
214
  function resolveRecipeFile(file: string): string {
298
215
  return (
299
- RecipeReferences.getRecipePath(file, DEFAULT_RECIPE_ROOT) ??
300
- RecipeReferences.resolveRecipePath(file, DEFAULT_RECIPE_ROOT)
216
+ RecipesReferences.getRecipePath(file, DEFAULT_RECIPE_ROOT) ??
217
+ RecipesReferences.resolveRecipePath(file, DEFAULT_RECIPE_ROOT)
301
218
  );
302
219
  }
303
220
 
@@ -309,10 +226,10 @@ function isMutableUsageRecipeFile(file: string): boolean {
309
226
 
310
227
  function readRecipeFile(file: string): AsyncRunStartParams {
311
228
  const path = resolveRecipeFile(file);
312
- const raw = RecipeReferences.readRawRecipeConfig(path);
229
+ const raw = RecipesReferences.readRawRecipeConfig(path);
313
230
  const includeActorRecipeContext =
314
231
  raw?.actor_context !== false && raw?.actor_context !== "off";
315
- const config = RecipeReferences.readResolvedRecipeConfig(path, [], {
232
+ const config = RecipesReferences.readResolvedRecipeConfig(path, [], {
316
233
  includeActorRecipeContext,
317
234
  });
318
235
  if (!config) {
@@ -356,128 +273,12 @@ function readJson(path: string): Record<string, unknown> | undefined {
356
273
  ).value;
357
274
  }
358
275
 
359
- function isAlive(pid: number): boolean {
360
- try {
361
- process.kill(pid, 0);
362
- return true;
363
- } catch {
364
- return false;
365
- }
366
- }
367
-
368
- function pidMatchesRun(pid: number, cwd: string, stateDir: string): boolean {
369
- if (platform() !== "linux" || !existsSync(`/proc/${pid}`))
370
- return isAlive(pid);
371
- try {
372
- const procCwd = readlinkSync(`/proc/${pid}/cwd`);
373
- const cmdline = readFileSync(`/proc/${pid}/cmdline`, "utf8");
374
- return (
375
- procCwd === resolve(cwd) &&
376
- cmdline.includes(RUNNER_PATH) &&
377
- cmdline.includes(stateDir)
378
- );
379
- } catch {
380
- return false;
381
- }
382
- }
383
-
384
- function isWithinRunnerIdentityGrace(meta: Record<string, unknown>): boolean {
385
- const createdAt =
386
- typeof meta.createdAt === "string" ? Date.parse(meta.createdAt) : NaN;
387
- return (
388
- Number.isFinite(createdAt) &&
389
- Date.now() - createdAt >= 0 &&
390
- Date.now() - createdAt <= RUNNER_IDENTITY_GRACE_MS
391
- );
392
- }
393
-
394
- function tailFile(path: string, lines: number): string {
395
- if (!existsSync(path)) return "";
396
- const content = readFileSync(path, "utf8").trimEnd();
397
- if (!content) return "";
398
- return content.split("\n").slice(-lines).join("\n");
399
- }
400
-
401
- function tailLines(path: string, lines: number): string[] {
402
- const content = tailFile(path, lines);
403
- return content ? content.split("\n") : [];
404
- }
405
-
406
- function getInterruptedRunStatus(
407
- stateDir: string,
408
- ): "cancelled" | "killed" | undefined {
409
- const events = readJsonlFileResilient<Record<string, unknown>>(
410
- join(stateDir, "events.jsonl"),
411
- ).records.slice(-200);
412
- for (const event of events.reverse()) {
413
- if (event.event === "run.kill") return "killed";
414
- if (event.event === "run.cancel") return "cancelled";
415
- }
416
- return undefined;
417
- }
418
-
419
276
  function acquireStateStartLock(stateDir: string): () => void {
420
- const lockDir = join(stateDir, ".start.lock");
421
- try {
422
- mkdirSync(lockDir);
423
- writeFileSync(
424
- join(lockDir, "owner.json"),
425
- `${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })}\n`,
426
- "utf8",
427
- );
428
- } catch (error) {
429
- try {
430
- const stat = statSync(lockDir);
431
- if (Date.now() - stat.mtimeMs > START_LOCK_MAX_AGE_MS) {
432
- rmSync(lockDir, { recursive: true, force: true });
433
- mkdirSync(lockDir);
434
- writeFileSync(
435
- join(lockDir, "owner.json"),
436
- `${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString(), recovered: true })}\n`,
437
- "utf8",
438
- );
439
- return () => rmSync(lockDir, { recursive: true, force: true });
440
- }
441
- } catch {
442
- // Keep the original lock acquisition error below.
443
- }
444
- throw new Error(
445
- `Run state is already being started: ${stateDir}. Retry after the current start finishes.`,
446
- { cause: error },
447
- );
448
- }
449
- return () => rmSync(lockDir, { recursive: true, force: true });
277
+ return RunsStart.acquireStateStartLock(stateDir);
450
278
  }
451
279
 
452
280
  function prepareStateDirForStart(stateDir: string): void {
453
- const existing = readJson(join(stateDir, "run.json"));
454
- const existingPid = Number(existing?.pid || 0);
455
- const existingCwd =
456
- typeof existing?.cwd === "string" ? existing.cwd : undefined;
457
- const existingResult = readJson(join(stateDir, "result.json"));
458
- if (
459
- !existingResult &&
460
- existingPid &&
461
- existingCwd &&
462
- isAlive(existingPid) &&
463
- pidMatchesRun(existingPid, existingCwd, stateDir)
464
- ) {
465
- throw new Error(
466
- `Run is already running: ${String(existing?.run ?? stateDir)}`,
467
- );
468
- }
469
- for (const file of [
470
- "events.jsonl",
471
- "inbox.jsonl",
472
- "outbox.jsonl",
473
- "progress.json",
474
- "result.json",
475
- "stderr.log",
476
- "stdout.log",
477
- "terminal-handled.json",
478
- ]) {
479
- rmSync(join(stateDir, file), { force: true });
480
- }
281
+ RunsStart.prepareStateDirForStart(stateDir, readJson, RUNNER_PATH);
481
282
  }
482
283
 
483
284
  export function startRun(
@@ -505,10 +306,10 @@ export function startRun(
505
306
  startParams.actor_context !== "off";
506
307
  const recipeContextRecords =
507
308
  recipeFile && includeActorRecipeContext
508
- ? RecipeReferences.buildRecipeContextRecords(recipeFile)
309
+ ? RecipesReferences.buildRecipeContextRecords(recipeFile)
509
310
  : undefined;
510
311
  if (recipeFile && isMutableUsageRecipeFile(recipeFile)) {
511
- RecipeUsage.recordRecipeLaunch(
312
+ RecipesUsage.recordRecipeLaunch(
512
313
  recipeFile,
513
314
  new Date(),
514
315
  startParams.launch_source === "tool" ? "tool" : "spawn",
@@ -585,78 +386,12 @@ export function startRun(
585
386
  }
586
387
  }
587
388
 
588
- function normalizeRunOutboxDelivery(value: unknown): RunOutboxDelivery {
589
- return value === "notify" || value === "followup" ? value : "log";
590
- }
591
-
592
- function normalizeRunOutboxLevel(value: unknown): RunOutboxLevel {
593
- return value === "warning" || value === "error" ? value : "info";
594
- }
595
-
596
- function normalizeRunOutboxEvent(
597
- raw: unknown,
598
- run: string,
599
- stateDir: string,
600
- index: number,
601
- ): RunOutboxEvent | undefined {
602
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
603
- const record = raw as Record<string, unknown>;
604
- const event =
605
- typeof record.event === "string" && record.event.trim()
606
- ? record.event.trim()
607
- : "run.event";
608
- const summary =
609
- typeof record.summary === "string" && record.summary.trim()
610
- ? record.summary.trim()
611
- : event;
612
- const ts =
613
- typeof record.ts === "string" && record.ts.trim()
614
- ? record.ts.trim()
615
- : new Date(0).toISOString();
616
- const id =
617
- typeof record.id === "string" && record.id.trim()
618
- ? record.id.trim()
619
- : `${run}:${index}`;
620
- return {
621
- ...(record.body !== undefined ? { body: record.body } : {}),
622
- ...(typeof record.correlation_id === "string"
623
- ? { correlation_id: record.correlation_id }
624
- : {}),
625
- ...(record.data !== undefined ? { data: record.data } : {}),
626
- delivery: normalizeRunOutboxDelivery(record.delivery),
627
- ...(record.metadata &&
628
- typeof record.metadata === "object" &&
629
- !Array.isArray(record.metadata)
630
- ? { metadata: record.metadata as Record<string, unknown> }
631
- : {}),
632
- event,
633
- ...(typeof record.from === "string" ? { from: record.from } : {}),
634
- id,
635
- level: normalizeRunOutboxLevel(record.level),
636
- ...(typeof record.reply_to === "string"
637
- ? { reply_to: record.reply_to }
638
- : {}),
639
- run,
640
- state_dir: stateDir,
641
- summary,
642
- ...(typeof record.to === "string" ? { to: record.to } : {}),
643
- ts,
644
- ...(typeof record.type === "string" ? { type: record.type } : {}),
645
- };
646
- }
647
-
648
- export function parseRunOutboxEventLine(
649
- line: string,
650
- run: string,
651
- stateDir: string,
652
- index: number,
653
- ): RunOutboxEvent | undefined {
654
- try {
655
- return normalizeRunOutboxEvent(JSON.parse(line), run, stateDir, index);
656
- } catch {
657
- return undefined;
658
- }
659
- }
389
+ export { parseRunOutboxEventLine } from "./runs-outbox.ts";
390
+ export type {
391
+ RunOutboxDelivery,
392
+ RunOutboxEvent,
393
+ RunOutboxLevel,
394
+ } from "./runs-outbox.ts";
660
395
 
661
396
  export function getRunStatus(runOrDir: string): Record<string, unknown> {
662
397
  const stateDir = resolve(
@@ -666,180 +401,43 @@ export function getRunStatus(runOrDir: string): Record<string, unknown> {
666
401
  );
667
402
  const meta = readJson(join(stateDir, "run.json"));
668
403
  if (!meta) throw new Error(`Run not found: ${runOrDir}`);
669
- const result = readJson(join(stateDir, "result.json"));
670
- const pid = Number(meta.pid || 0);
671
- const aliveOwnedRunner = Boolean(
672
- pid &&
673
- isAlive(pid) &&
674
- (!Array.isArray(meta.argv) ||
675
- pidMatchesRun(pid, String(meta.cwd ?? ""), stateDir) ||
676
- isWithinRunnerIdentityGrace(meta)),
404
+ return buildRunStatus(
405
+ stateDir,
406
+ runOrDir,
407
+ meta,
408
+ readJson,
409
+ RUNNER_PATH,
410
+ RUNNER_IDENTITY_GRACE_MS,
677
411
  );
678
- const status: AsyncRunStatus = result
679
- ? Number(result.code ?? 0) === 0
680
- ? "done"
681
- : "failed"
682
- : aliveOwnedRunner
683
- ? "running"
684
- : (getInterruptedRunStatus(stateDir) ?? "exited");
685
- const terminalHandled = readJson(join(stateDir, "terminal-handled.json"));
686
- return {
687
- ...meta,
688
- eventsFile: join(stateDir, "events.jsonl"),
689
- inboxFile: join(stateDir, "inbox.jsonl"),
690
- outboxFile: join(stateDir, "outbox.jsonl"),
691
- progress: readJson(join(stateDir, "progress.json")) || null,
692
- result: result || null,
693
- ...(terminalHandled ? { terminal_handled: terminalHandled } : {}),
694
- state_dir: String(meta.state_dir ?? stateDir),
695
- stderrLog: join(stateDir, "stderr.log"),
696
- stdoutLog: join(stateDir, "stdout.log"),
697
- status,
698
- };
699
412
  }
700
413
 
701
- export interface RunStateIndexEntry {
702
- ownerId?: string;
703
- recipe?: string;
704
- run: string;
705
- state_dir: string;
706
- status: AsyncRunStatus;
707
- tool?: string;
708
- updated_at?: string;
709
- }
710
-
711
- function matchesStatusFilter(
712
- status: unknown,
713
- filter: string | undefined,
714
- ): boolean {
715
- if (!filter || filter === "all") return true;
716
- if (filter === "active") return status === "running";
717
- if (filter === "terminal") return status !== "running";
718
- return status === filter;
719
- }
414
+ export type { RunStateIndexEntry } from "./runs-index.ts";
720
415
 
721
416
  export function listRunStateDirs(
722
417
  stateRoot = DEFAULT_STATE_ROOT,
723
418
  depth = 0,
724
419
  seen = new Set<string>(),
725
420
  ): string[] {
726
- if (!existsSync(stateRoot) || seen.has(resolve(stateRoot))) return [];
727
- seen.add(resolve(stateRoot));
728
- let entries: import("node:fs").Dirent[];
729
- try {
730
- entries = readdirSync(stateRoot, { withFileTypes: true });
731
- } catch {
732
- return [];
733
- }
734
- const result: string[] = [];
735
- for (const entry of entries) {
736
- if (!entry.isDirectory()) continue;
737
- const child = join(stateRoot, entry.name);
738
- if (existsSync(join(child, "run.json"))) result.push(child);
739
- if (depth + 1 < 8) result.push(...listRunStateDirs(child, depth + 1, seen));
740
- }
741
- return result;
742
- }
743
-
744
- function runIndexPath(stateRoot: string): string {
745
- return join(stateRoot, "index.json");
746
- }
747
-
748
- function indexEntryFromStatus(
749
- status: Record<string, unknown>,
750
- ): RunStateIndexEntry {
751
- const progress =
752
- status.progress && typeof status.progress === "object"
753
- ? (status.progress as Record<string, unknown>)
754
- : {};
755
- return {
756
- ...(typeof status.ownerId === "string" ? { ownerId: status.ownerId } : {}),
757
- ...(typeof status.recipe === "string" ? { recipe: status.recipe } : {}),
758
- run: String(status.run),
759
- state_dir: String(status.state_dir),
760
- status: status.status as AsyncRunStatus,
761
- ...(typeof status.tool === "string" ? { tool: status.tool } : {}),
762
- updated_at:
763
- typeof progress.updatedAt === "string"
764
- ? progress.updatedAt
765
- : typeof status.createdAt === "string"
766
- ? status.createdAt
767
- : new Date(0).toISOString(),
768
- };
421
+ return RunsIndex.listRunStateDirs(stateRoot, depth, seen);
769
422
  }
770
423
 
771
424
  export function rebuildRunStateIndex(
772
425
  stateRoot = DEFAULT_STATE_ROOT,
773
- ): RunStateIndexEntry[] {
774
- mkdirSync(stateRoot, { recursive: true });
775
- const entries = listRunStateDirs(stateRoot)
776
- .flatMap((stateDir) => {
777
- try {
778
- return [indexEntryFromStatus(getRunStatus(stateDir))];
779
- } catch {
780
- return [];
781
- }
782
- })
783
- .sort((a, b) => (b.updated_at ?? "").localeCompare(a.updated_at ?? ""));
784
- writeJsonAtomic(runIndexPath(stateRoot), {
785
- entries,
786
- rebuilt_at: new Date().toISOString(),
787
- });
788
- return entries;
426
+ ): RunsIndex.RunStateIndexEntry[] {
427
+ return RunsIndex.rebuildRunStateIndex(stateRoot, getRunStatus);
789
428
  }
790
429
 
791
430
  export function readRunStateIndex(
792
431
  stateRoot = DEFAULT_STATE_ROOT,
793
- ): RunStateIndexEntry[] | undefined {
794
- const index = readJson(runIndexPath(stateRoot));
795
- if (!index || typeof index !== "object") return undefined;
796
- const entries = (index as Record<string, unknown>).entries;
797
- if (!Array.isArray(entries)) return undefined;
798
- const valid = entries.filter((entry): entry is RunStateIndexEntry => {
799
- const record =
800
- entry && typeof entry === "object"
801
- ? (entry as Record<string, unknown>)
802
- : {};
803
- return (
804
- typeof record.run === "string" &&
805
- typeof record.state_dir === "string" &&
806
- typeof record.status === "string"
807
- );
808
- });
809
- if (valid.some((entry) => !existsSync(join(entry.state_dir, "run.json"))))
810
- return undefined;
811
- const indexedDirs = new Set(valid.map((entry) => resolve(entry.state_dir)));
812
- const stateDirs = listRunStateDirs(stateRoot).map((stateDir) => resolve(stateDir));
813
- if (stateDirs.some((stateDir) => !indexedDirs.has(stateDir))) return undefined;
814
- return valid;
432
+ ): RunsIndex.RunStateIndexEntry[] | undefined {
433
+ return RunsIndex.readRunStateIndex(stateRoot, readJson);
815
434
  }
816
435
 
817
436
  export function listRuns(
818
437
  stateRoot = DEFAULT_STATE_ROOT,
819
438
  statusFilter?: string,
820
439
  ): Array<Record<string, unknown>> {
821
- if (!existsSync(stateRoot)) return [];
822
- const indexed = readRunStateIndex(stateRoot);
823
- if (indexed) {
824
- return indexed
825
- .filter((entry) => matchesStatusFilter(entry.status, statusFilter))
826
- .map((entry) => ({
827
- run: entry.run,
828
- state_dir: entry.state_dir,
829
- status: entry.status,
830
- ...(entry.tool ? { tool: entry.tool } : {}),
831
- ...(entry.recipe ? { recipe: entry.recipe } : {}),
832
- }));
833
- }
834
- return rebuildRunStateIndex(stateRoot)
835
- .filter((entry) => matchesStatusFilter(entry.status, statusFilter))
836
- .map((entry) => ({
837
- run: entry.run,
838
- state_dir: entry.state_dir,
839
- status: entry.status,
840
- ...(entry.tool ? { tool: entry.tool } : {}),
841
- ...(entry.recipe ? { recipe: entry.recipe } : {}),
842
- }));
440
+ return RunsIndex.listRuns(stateRoot, getRunStatus, readJson, statusFilter);
843
441
  }
844
442
 
845
443
  export function tailRun(runOrDir: string, lines = 40): string {
@@ -862,86 +460,11 @@ export function readRunEvents(runOrDir: string, lines = 40): RunOutboxEvent[] {
862
460
  .filter((event): event is RunOutboxEvent => Boolean(event));
863
461
  }
864
462
 
865
- export type RunInboxStatus =
866
- | "queued"
867
- | "sent"
868
- | "claimed"
869
- | "handled"
870
- | "failed";
871
-
872
- export type RunInboxMessage = Record<string, unknown> & {
873
- id?: string;
874
- status?: RunInboxStatus | string;
875
- };
876
-
877
- export interface ProcessRunInboxResult {
878
- claimed: number;
879
- failed: number;
880
- handled: number;
881
- }
882
-
883
- function runInboxFile(stateDir: string): string {
884
- return join(stateDir, "inbox.jsonl");
885
- }
886
-
887
- function sleepSync(ms: number): void {
888
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
889
- }
890
-
891
- function acquireRunInboxLock(stateDir: string): () => void {
892
- const lockDir = join(stateDir, ".inbox.lock");
893
- const started = Date.now();
894
- while (true) {
895
- try {
896
- mkdirSync(lockDir, { recursive: false });
897
- writeFileSync(
898
- join(lockDir, "owner.json"),
899
- `${JSON.stringify({ pid: process.pid, created_at: new Date().toISOString() })}\n`,
900
- "utf8",
901
- );
902
- return () => rmSync(lockDir, { recursive: true, force: true });
903
- } catch (error) {
904
- try {
905
- const stat = statSync(lockDir);
906
- if (Date.now() - stat.mtimeMs > START_LOCK_MAX_AGE_MS) {
907
- rmSync(lockDir, { recursive: true, force: true });
908
- continue;
909
- }
910
- } catch {
911
- continue;
912
- }
913
- if (Date.now() - started > RUN_INBOX_LOCK_TIMEOUT_MS) {
914
- throw new Error("Run inbox lock timed out.", { cause: error });
915
- }
916
- sleepSync(10);
917
- }
918
- }
919
- }
920
-
921
- function parseRunInboxLine(line: string): RunInboxMessage | undefined {
922
- try {
923
- return JSON.parse(line) as RunInboxMessage;
924
- } catch {
925
- return undefined;
926
- }
927
- }
928
-
929
- function readRunInboxMessagesFromStateDir(stateDir: string): RunInboxMessage[] {
930
- return readJsonlFileResilient<RunInboxMessage>(runInboxFile(stateDir)).records;
931
- }
932
-
933
- function writeRunInboxMessages(
934
- stateDir: string,
935
- messages: RunInboxMessage[],
936
- ): void {
937
- writeFileSync(
938
- runInboxFile(stateDir),
939
- messages.length
940
- ? `${messages.map((message) => JSON.stringify(message)).join("\n")}\n`
941
- : "",
942
- "utf8",
943
- );
944
- }
463
+ export type {
464
+ ProcessRunInboxResult,
465
+ RunInboxMessage,
466
+ RunInboxStatus,
467
+ } from "./runs-mailbox.ts";
945
468
 
946
469
  export function readRunInboxMessages(
947
470
  runOrDir: string,
@@ -962,26 +485,12 @@ export function updateRunInboxMessageStatus(
962
485
  ): boolean {
963
486
  const status = getRunStatus(runOrDir);
964
487
  const stateDir = String(status.state_dir);
965
- const releaseLock = acquireRunInboxLock(stateDir);
966
- try {
967
- const messages = readRunInboxMessagesFromStateDir(stateDir);
968
- const timestampKey = `${nextStatus}_at`;
969
- let changed = false;
970
- const updated = messages.map((message) => {
971
- if (message.id !== id) return message;
972
- changed = true;
973
- return {
974
- ...message,
975
- ...metadata,
976
- [timestampKey]: new Date().toISOString(),
977
- status: nextStatus,
978
- };
979
- });
980
- if (changed) writeRunInboxMessages(stateDir, updated);
981
- return changed;
982
- } finally {
983
- releaseLock();
984
- }
488
+ return updateRunInboxMessageStatusInStateDir(
489
+ stateDir,
490
+ id,
491
+ nextStatus,
492
+ metadata,
493
+ );
985
494
  }
986
495
 
987
496
  export function claimRunInboxMessage(
@@ -991,29 +500,7 @@ export function claimRunInboxMessage(
991
500
  ): RunInboxMessage | undefined {
992
501
  const status = getRunStatus(runOrDir);
993
502
  const stateDir = String(status.state_dir);
994
- const releaseLock = acquireRunInboxLock(stateDir);
995
- try {
996
- const messages = readRunInboxMessagesFromStateDir(stateDir);
997
- const index = messages.findIndex((message) =>
998
- statuses.includes(String(message.status ?? "queued")),
999
- );
1000
- if (index < 0) return undefined;
1001
- const claimed = {
1002
- ...messages[index],
1003
- claimed_at: new Date().toISOString(),
1004
- claimed_by: owner,
1005
- id:
1006
- typeof messages[index].id === "string"
1007
- ? messages[index].id
1008
- : randomUUID(),
1009
- status: "claimed",
1010
- } satisfies RunInboxMessage;
1011
- messages[index] = claimed;
1012
- writeRunInboxMessages(stateDir, messages);
1013
- return claimed;
1014
- } finally {
1015
- releaseLock();
1016
- }
503
+ return claimRunInboxMessageInStateDir(stateDir, owner, statuses);
1017
504
  }
1018
505
 
1019
506
  export async function processRunInboxMessages(
@@ -1021,29 +508,12 @@ export async function processRunInboxMessages(
1021
508
  handler: (message: RunInboxMessage) => Promise<void> | void,
1022
509
  options: { limit?: number; owner?: string; statuses?: string[] } = {},
1023
510
  ): Promise<ProcessRunInboxResult> {
1024
- const result: ProcessRunInboxResult = { claimed: 0, failed: 0, handled: 0 };
1025
- const limit = Math.max(1, Number(options.limit ?? 1));
1026
- const owner = options.owner ?? "runtime";
1027
- for (let index = 0; index < limit; index += 1) {
1028
- const message = claimRunInboxMessage(runOrDir, owner, options.statuses);
1029
- if (!message?.id) break;
1030
- result.claimed += 1;
1031
- try {
1032
- await handler(message);
1033
- if (updateRunInboxMessageStatus(runOrDir, message.id, "handled")) {
1034
- result.handled += 1;
1035
- }
1036
- } catch (error) {
1037
- if (
1038
- updateRunInboxMessageStatus(runOrDir, message.id, "failed", {
1039
- error: error instanceof Error ? error.message : String(error),
1040
- })
1041
- ) {
1042
- result.failed += 1;
1043
- }
1044
- }
1045
- }
1046
- return result;
511
+ const status = getRunStatus(runOrDir);
512
+ return processRunInboxMessagesInStateDir(
513
+ String(status.state_dir),
514
+ handler,
515
+ options,
516
+ );
1047
517
  }
1048
518
 
1049
519
  export function appendRunOutboxEvent(
@@ -1066,32 +536,7 @@ export function appendRunOutboxEvent(
1066
536
  const status = getRunStatus(runOrDir);
1067
537
  const stateDir = String(status.state_dir);
1068
538
  const run = String(status.run ?? runOrDir);
1069
- const type = event.type || event.event || "run.message";
1070
- const to = event.to || "coordinator";
1071
- const metadata = event.metadata ?? {};
1072
- const requiresResponse = metadata.requires_response === true;
1073
- const payload = {
1074
- ...(event.body !== undefined ? { body: event.body } : {}),
1075
- ...(event.correlation_id ? { correlation_id: event.correlation_id } : {}),
1076
- ...(event.data !== undefined ? { data: event.data } : {}),
1077
- delivery: normalizeRunOutboxDelivery(
1078
- event.delivery ??
1079
- (requiresResponse
1080
- ? "followup"
1081
- : to === "coordinator"
1082
- ? "notify"
1083
- : "log"),
1084
- ),
1085
- event: type,
1086
- from: event.from || `run:${run}`,
1087
- level: normalizeRunOutboxLevel(event.level),
1088
- ...(event.metadata ? { metadata: event.metadata } : {}),
1089
- ...(event.reply_to ? { reply_to: event.reply_to } : {}),
1090
- summary: event.summary || type,
1091
- to,
1092
- ts: new Date().toISOString(),
1093
- type,
1094
- };
539
+ const payload = buildRunOutboxEventPayload(run, event);
1095
540
  const line = `${JSON.stringify(payload)}\n`;
1096
541
  writeFileSync(join(stateDir, "outbox.jsonl"), line, { flag: "a" });
1097
542
  return {
@@ -1103,148 +548,7 @@ export function appendRunOutboxEvent(
1103
548
  };
1104
549
  }
1105
550
 
1106
- export interface SendRunMessageOptions {
1107
- namedPipeSend?: (path: string, payload: string) => Promise<number>;
1108
- platform?: NodeJS.Platform;
1109
- }
1110
-
1111
- function getRunControlEndpoint(
1112
- status: Record<string, unknown>,
1113
- stateDir: string,
1114
- ): AsyncRunControlEndpoint {
1115
- const control = status.control;
1116
- if (control && typeof control === "object" && !Array.isArray(control)) {
1117
- const record = control as Record<string, unknown>;
1118
- if (
1119
- (record.type === "fifo" ||
1120
- record.type === "mailbox" ||
1121
- record.type === "named-pipe") &&
1122
- typeof record.path === "string" &&
1123
- record.path.trim()
1124
- ) {
1125
- return { path: record.path, type: record.type };
1126
- }
1127
- }
1128
- return { path: join(stateDir, "control.fifo"), type: "fifo" };
1129
- }
1130
-
1131
- function appendRunInboxMessage(stateDir: string, message: string): string {
1132
- const id = randomUUID();
1133
- const ts = new Date().toISOString();
1134
- let record: Record<string, unknown>;
1135
- try {
1136
- const parsed = JSON.parse(message) as unknown;
1137
- record =
1138
- parsed && typeof parsed === "object" && !Array.isArray(parsed)
1139
- ? (parsed as Record<string, unknown>)
1140
- : { body: parsed, type: "run.message" };
1141
- } catch {
1142
- record = { body: message, type: "run.message" };
1143
- }
1144
- const releaseLock = acquireRunInboxLock(stateDir);
1145
- try {
1146
- writeFileSync(
1147
- runInboxFile(stateDir),
1148
- `${JSON.stringify({ ...record, id, queued_at: ts, received_at: ts, status: "queued" })}\n`,
1149
- { flag: "a" },
1150
- );
1151
- } finally {
1152
- releaseLock();
1153
- }
1154
- return id;
1155
- }
1156
-
1157
- function writeRunMessageReceipt(
1158
- stateDir: string,
1159
- message: string,
1160
- bytes: number,
1161
- inboxId: string,
1162
- ): void {
1163
- const trimmedMessage = message.trim().toLowerCase();
1164
- const terminalMessage = ["stop", "cancel", "quit", "exit"].includes(
1165
- trimmedMessage,
1166
- );
1167
- const ts = new Date().toISOString();
1168
- writeFileSync(
1169
- join(stateDir, "events.jsonl"),
1170
- `${JSON.stringify({ bytes, event: "run.message", inbox_id: inboxId, terminal: terminalMessage || undefined, ts })}\n`,
1171
- { flag: "a" },
1172
- );
1173
- updateRunInboxMessageStatus(stateDir, inboxId, "sent", { bytes });
1174
- if (terminalMessage) {
1175
- markTerminalHandled(stateDir, {
1176
- event: "run.message",
1177
- message: trimmedMessage,
1178
- });
1179
- }
1180
- }
1181
-
1182
- function sendRunMessageToFifo(
1183
- endpoint: AsyncRunControlEndpoint,
1184
- payload: string,
1185
- ): number {
1186
- if (!existsSync(endpoint.path))
1187
- throw new Error(`Run control FIFO not found: ${endpoint.path}`);
1188
- const stat = statSync(endpoint.path);
1189
- if ((stat.mode & constants.S_IFMT) !== constants.S_IFIFO) {
1190
- throw new Error(`Run control endpoint is not a FIFO: ${endpoint.path}`);
1191
- }
1192
- let fd: number | undefined;
1193
- try {
1194
- fd = openSync(endpoint.path, constants.O_WRONLY | constants.O_NONBLOCK);
1195
- return writeSync(fd, payload);
1196
- } finally {
1197
- if (fd !== undefined) closeSync(fd);
1198
- }
1199
- }
1200
-
1201
- function notifyRunMessageWake(
1202
- stateDir: string,
1203
- run: string,
1204
- bytes: number,
1205
- endpoint: AsyncRunControlEndpoint,
1206
- inboxId: string,
1207
- ): Record<string, unknown> | undefined {
1208
- try {
1209
- const event = notifyRuntimeWake(stateDir, {
1210
- actor: `run:${run}`,
1211
- metadata: { bytes, control_type: endpoint.type, inbox_id: inboxId },
1212
- reason: "run.message",
1213
- });
1214
- return { wake: "wake.jsonl", wake_id: event.id };
1215
- } catch {
1216
- return undefined;
1217
- }
1218
- }
1219
-
1220
- function sendRunMessageToNamedPipe(
1221
- endpoint: AsyncRunControlEndpoint,
1222
- payload: string,
1223
- send?: (path: string, payload: string) => Promise<number>,
1224
- ): Promise<number> {
1225
- if (send) return send(endpoint.path, payload);
1226
- return new Promise((resolve, reject) => {
1227
- const socket = createConnection(endpoint.path);
1228
- let settled = false;
1229
- const timeout = setTimeout(() => {
1230
- if (settled) return;
1231
- settled = true;
1232
- socket.destroy();
1233
- reject(new Error("named pipe connection timed out"));
1234
- }, 5000);
1235
- const finish = (error?: Error): void => {
1236
- if (settled) return;
1237
- settled = true;
1238
- clearTimeout(timeout);
1239
- if (error) reject(error);
1240
- else resolve(Buffer.byteLength(payload));
1241
- };
1242
- socket.on("error", finish);
1243
- socket.on("connect", () => {
1244
- socket.end(payload, () => finish());
1245
- });
1246
- });
1247
- }
551
+ export type { SendRunMessageOptions } from "./runs-messages.ts";
1248
552
 
1249
553
  export async function sendRunMessage(
1250
554
  runOrDir: string,
@@ -1259,155 +563,15 @@ export async function sendRunMessage(
1259
563
  const pid = Number(status.pid || 0);
1260
564
  if (!pid || !isAlive(pid)) throw new Error(`Run pid is not alive: ${run}`);
1261
565
  if (
1262
- !pidMatchesRun(pid, String(status.cwd), stateDir) &&
1263
- !isWithinRunnerIdentityGrace(status)
566
+ !pidMatchesRun(pid, String(status.cwd), stateDir, RUNNER_PATH) &&
567
+ !isWithinRunnerIdentityGrace(status, RUNNER_IDENTITY_GRACE_MS)
1264
568
  )
1265
569
  throw new Error(`Run pid owner mismatch: ${run}`);
1266
- const endpoint = getRunControlEndpoint(status, stateDir);
1267
- const payload = message.endsWith("\n") ? message : `${message}\n`;
1268
- const payloadBytes = Buffer.byteLength(payload);
1269
- const inboxId = appendRunInboxMessage(stateDir, message);
1270
- const wake = notifyRunMessageWake(
1271
- stateDir,
1272
- run,
1273
- payloadBytes,
1274
- endpoint,
1275
- inboxId,
1276
- );
1277
- const runtimePlatform = options.platform ?? process.platform;
1278
- if (endpoint.type === "mailbox") {
1279
- return {
1280
- ...(wake ?? {}),
1281
- bytes: payloadBytes,
1282
- control: "inbox.jsonl",
1283
- control_path: endpoint.path,
1284
- control_type: endpoint.type,
1285
- inbox_id: inboxId,
1286
- queued: true,
1287
- run,
1288
- sent: true,
1289
- state_dir: stateDir,
1290
- };
1291
- }
1292
- try {
1293
- if (endpoint.type === "fifo") {
1294
- if (runtimePlatform === "win32") {
1295
- throw new Error(
1296
- "run actor messages on native Windows require a named-pipe control endpoint; this recipe still exposes Unix FIFO control.",
1297
- );
1298
- }
1299
- const bytes = sendRunMessageToFifo(endpoint, payload);
1300
- writeRunMessageReceipt(stateDir, message, bytes, inboxId);
1301
- return {
1302
- ...(wake ?? {}),
1303
- bytes,
1304
- control: "control.fifo",
1305
- control_path: endpoint.path,
1306
- control_type: endpoint.type,
1307
- inbox_id: inboxId,
1308
- run,
1309
- sent: true,
1310
- state_dir: stateDir,
1311
- };
1312
- }
1313
- const bytes = await sendRunMessageToNamedPipe(
1314
- endpoint,
1315
- payload,
1316
- options.namedPipeSend,
1317
- );
1318
- writeRunMessageReceipt(stateDir, message, bytes, inboxId);
1319
- return {
1320
- ...(wake ?? {}),
1321
- bytes,
1322
- control: endpoint.path,
1323
- control_path: endpoint.path,
1324
- control_type: endpoint.type,
1325
- inbox_id: inboxId,
1326
- run,
1327
- sent: true,
1328
- state_dir: stateDir,
1329
- };
1330
- } catch (error) {
1331
- const deliveryError =
1332
- error instanceof Error ? error.message : String(error);
1333
- throw Object.assign(
1334
- new Error(
1335
- `Run control endpoint is not ready: ${endpoint.path}: ${deliveryError}`,
1336
- ),
1337
- {
1338
- control_path: endpoint.path,
1339
- control_type: endpoint.type,
1340
- delivery_error: deliveryError,
1341
- inbox_id: inboxId,
1342
- queued: true,
1343
- run,
1344
- sent: false,
1345
- state_dir: stateDir,
1346
- },
1347
- );
1348
- }
1349
- }
1350
-
1351
- export interface RunProcessSignalPlan {
1352
- args?: string[];
1353
- command?: string;
1354
- signalTarget: "processGroup" | "process" | "processTree";
1355
- }
1356
-
1357
- export function getRunProcessSignalPlan(
1358
- pid: number,
1359
- signal: NodeJS.Signals,
1360
- runtimePlatform: NodeJS.Platform = process.platform,
1361
- ): RunProcessSignalPlan {
1362
- if (runtimePlatform === "win32") {
1363
- return {
1364
- args: [
1365
- "/PID",
1366
- String(pid),
1367
- "/T",
1368
- ...(signal === "SIGKILL" ? ["/F"] : []),
1369
- ],
1370
- command: "taskkill",
1371
- signalTarget: "processTree",
1372
- };
1373
- }
1374
- return { signalTarget: "processGroup" };
1375
- }
1376
-
1377
- function signalOwnedRunProcess(
1378
- pid: number,
1379
- signal: NodeJS.Signals,
1380
- ): RunProcessSignalPlan {
1381
- const plan = getRunProcessSignalPlan(pid, signal);
1382
- if (plan.command && plan.args) {
1383
- const result = spawnSync(plan.command, plan.args, { encoding: "utf8" });
1384
- if (result.status !== 0) {
1385
- throw new Error(
1386
- result.stderr?.trim() ||
1387
- result.stdout?.trim() ||
1388
- `${plan.command} failed`,
1389
- );
1390
- }
1391
- return plan;
1392
- }
1393
- try {
1394
- process.kill(-pid, signal);
1395
- return { signalTarget: "processGroup" };
1396
- } catch {
1397
- process.kill(pid, signal);
1398
- return { signalTarget: "process" };
1399
- }
570
+ return deliverRunMessage(status, run, stateDir, message, options);
1400
571
  }
1401
572
 
1402
- function markTerminalHandled(
1403
- stateDir: string,
1404
- details: Record<string, unknown>,
1405
- ): void {
1406
- writeJsonAtomic(join(stateDir, "terminal-handled.json"), {
1407
- ...details,
1408
- ts: new Date().toISOString(),
1409
- });
1410
- }
573
+ export { getRunProcessSignalPlan } from "./runs-control.ts";
574
+ export type { RunProcessSignalPlan } from "./runs-control.ts";
1411
575
 
1412
576
  function markTerminalProgress(
1413
577
  stateDir: string,
@@ -1417,15 +581,11 @@ function markTerminalProgress(
1417
581
  const progress =
1418
582
  existing && typeof existing === "object" && !Array.isArray(existing)
1419
583
  ? (existing as Record<string, unknown>)
1420
- : {};
1421
- const { activeSubagents: _activeSubagents, ...rest } = progress;
1422
- writeJsonAtomic(join(stateDir, "progress.json"), {
1423
- ...rest,
1424
- completed: typeof progress.completed === "number" ? progress.completed : 0,
1425
- failures: Array.isArray(progress.failures) ? progress.failures : [],
1426
- phase,
1427
- updatedAt: new Date().toISOString(),
1428
- });
584
+ : undefined;
585
+ writeJsonAtomic(
586
+ join(stateDir, "progress.json"),
587
+ buildTerminalProgress(progress, phase),
588
+ );
1429
589
  }
1430
590
 
1431
591
  function stopRun(
@@ -1440,7 +600,7 @@ function stopRun(
1440
600
  return { stopped: false, reason: "not running", status };
1441
601
  if (!pid || !isAlive(pid))
1442
602
  return { stopped: false, reason: "pid not alive", status };
1443
- if (!pidMatchesRun(pid, String(status.cwd), stateDir)) {
603
+ if (!pidMatchesRun(pid, String(status.cwd), stateDir, RUNNER_PATH)) {
1444
604
  return { stopped: false, reason: "pid owner mismatch", status };
1445
605
  }
1446
606
  const signalResult = signalOwnedRunProcess(pid, signal);
@@ -1470,67 +630,15 @@ function assertTerminalRun(runOrDir: string): Record<string, unknown> {
1470
630
  return status;
1471
631
  }
1472
632
 
1473
- function archivePathFor(run: string, stateDir: string): string {
1474
- const archiveRoot = join(dirname(stateDir), "archived");
1475
- mkdirSync(archiveRoot, { recursive: true });
1476
- return join(
1477
- archiveRoot,
1478
- `${safeRunId(run)}-${new Date().toISOString().replace(/[:.]/g, "-")}`,
1479
- );
1480
- }
1481
-
1482
633
  export function archiveRun(runOrDir: string): Record<string, unknown> {
1483
- const status = assertTerminalRun(runOrDir);
1484
- const stateDir = String(status.state_dir);
1485
- const run = String(status.run ?? basename(stateDir));
1486
- const archiveDir = archivePathFor(run, stateDir);
1487
- renameSync(stateDir, archiveDir);
1488
- mkdirSync(stateDir, { recursive: true });
1489
- const tombstone = {
1490
- archived: true,
1491
- archive_dir: archiveDir,
1492
- original_state_dir: stateDir,
1493
- run,
1494
- status: status.status,
1495
- ts: new Date().toISOString(),
1496
- };
1497
- writeJsonAtomic(join(stateDir, "archive-tombstone.json"), tombstone);
1498
- return tombstone;
634
+ return archiveTerminalRun(assertTerminalRun(runOrDir));
1499
635
  }
1500
636
 
1501
637
  export function pruneRun(
1502
638
  runOrDir: string,
1503
639
  options: { preserveArtifacts?: boolean } = {},
1504
640
  ): Record<string, unknown> {
1505
- const status = assertTerminalRun(runOrDir);
1506
- const stateDir = String(status.state_dir);
1507
- const run = String(status.run ?? basename(stateDir));
1508
- const manifest = resolveArtifactManifest(
1509
- status.artifacts as Record<string, RunArtifactDeclaration> | undefined,
1510
- );
1511
- const preserved: Record<string, string> = {};
1512
- if (options.preserveArtifacts && manifest) {
1513
- const preserveRoot = join(
1514
- dirname(stateDir),
1515
- "preserved-artifacts",
1516
- safeRunId(run),
1517
- );
1518
- mkdirSync(preserveRoot, { recursive: true });
1519
- for (const [name, artifact] of Object.entries(manifest)) {
1520
- if (!artifact.exists) continue;
1521
- const target = join(preserveRoot, basename(artifact.path));
1522
- cpSync(artifact.path, target, { force: true });
1523
- preserved[name] = target;
1524
- }
1525
- }
1526
- rmSync(stateDir, { recursive: true, force: true });
1527
- return {
1528
- pruned: true,
1529
- preserved_artifacts: preserved,
1530
- run,
1531
- state_dir: stateDir,
1532
- ts: new Date().toISOString(),
1533
- };
641
+ return pruneTerminalRun(assertTerminalRun(runOrDir), options);
1534
642
  }
1535
643
 
1536
644
  export function killRun(runOrDir: string): Record<string, unknown> {