@llblab/pi-actors 0.43.0 → 0.43.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 (95) hide show
  1. package/AGENTS.md +12 -6
  2. package/BACKLOG.md +189 -1
  3. package/CHANGELOG.md +180 -301
  4. package/README.md +9 -7
  5. package/dist/index.js +1 -1
  6. package/dist/lib/async-runs.d.ts +2 -1
  7. package/dist/lib/async-runs.js +16 -4
  8. package/dist/lib/automatic-review-runtime.d.ts +1 -1
  9. package/dist/lib/automatic-review-runtime.js +5 -5
  10. package/dist/lib/command-templates.d.ts +2 -0
  11. package/dist/lib/command-templates.js +38 -4
  12. package/dist/lib/control-projection.d.ts +20 -0
  13. package/dist/lib/control-projection.js +66 -0
  14. package/dist/lib/control.d.ts +3 -0
  15. package/dist/lib/control.js +27 -14
  16. package/dist/lib/draft-sleep.js +3 -3
  17. package/dist/lib/file-state.d.ts +1 -0
  18. package/dist/lib/file-state.js +98 -42
  19. package/dist/lib/inspector-overlay.d.ts +2 -0
  20. package/dist/lib/inspector-overlay.js +92 -53
  21. package/dist/lib/limits.d.ts +5 -3
  22. package/dist/lib/limits.js +5 -3
  23. package/dist/lib/prompts.d.ts +1 -1
  24. package/dist/lib/prompts.js +1 -1
  25. package/dist/lib/recipe-control.js +6 -2
  26. package/dist/lib/review-control.d.ts +1 -1
  27. package/dist/lib/review-control.js +4 -5
  28. package/dist/lib/runs-control-delivery.d.ts +8 -1
  29. package/dist/lib/runs-control-delivery.js +38 -15
  30. package/dist/lib/runs-controls.d.ts +2 -0
  31. package/dist/lib/runs-controls.js +5 -3
  32. package/dist/lib/runs-trace.d.ts +2 -2
  33. package/dist/lib/runs-trace.js +24 -20
  34. package/dist/lib/runtime-identity.d.ts +7 -0
  35. package/dist/lib/runtime-identity.js +35 -0
  36. package/dist/lib/runtime-triage.d.ts +29 -0
  37. package/dist/lib/runtime-triage.js +76 -0
  38. package/dist/lib/tool-review-scheduler.js +7 -7
  39. package/dist/lib/tools-inspect.js +53 -14
  40. package/dist/lib/tools-message.d.ts +1 -2
  41. package/dist/lib/tools-message.js +6 -6
  42. package/dist/lib/tools-response.d.ts +0 -1
  43. package/dist/lib/tools-response.js +0 -9
  44. package/dist/lib/tools.d.ts +1 -1
  45. package/dist/lib/tools.js +1 -1
  46. package/dist/lib/trace-projection.js +30 -10
  47. package/dist/scripts/locker.mjs +9 -16
  48. package/dist/scripts/music-player.mjs +7 -13
  49. package/dist/scripts/release-gates.mjs +33 -2
  50. package/dist/scripts/validate-recipe.mjs +5 -4
  51. package/dist/skills/actors/SKILL.md +12 -7
  52. package/dist/skills/swarm/SKILL.md +0 -2
  53. package/docs/0.43-baseline.md +18 -23
  54. package/docs/README.md +2 -4
  55. package/docs/actor-inspector.md +5 -5
  56. package/docs/async-runs.md +2 -2
  57. package/docs/command-templates.md +6 -116
  58. package/docs/recipe-library.md +2 -4
  59. package/docs/releasing.md +28 -0
  60. package/docs/template-recipes.md +1 -1
  61. package/docs/tool-registry.md +2 -2
  62. package/index.ts +1 -1
  63. package/lib/async-runs.ts +18 -5
  64. package/lib/automatic-review-runtime.ts +7 -7
  65. package/lib/command-templates.ts +44 -4
  66. package/lib/control-projection.ts +105 -0
  67. package/lib/control.ts +33 -18
  68. package/lib/draft-sleep.ts +3 -3
  69. package/lib/file-state.ts +68 -61
  70. package/lib/inspector-overlay.ts +93 -58
  71. package/lib/limits.ts +5 -3
  72. package/lib/prompts.ts +1 -1
  73. package/lib/recipe-control.ts +9 -2
  74. package/lib/review-control.ts +4 -5
  75. package/lib/runs-control-delivery.ts +45 -17
  76. package/lib/runs-controls.ts +12 -3
  77. package/lib/runs-trace.ts +23 -19
  78. package/lib/runtime-identity.ts +39 -0
  79. package/lib/runtime-triage.ts +120 -0
  80. package/lib/tool-review-scheduler.ts +7 -7
  81. package/lib/tools-inspect.ts +60 -16
  82. package/lib/tools-message.ts +7 -8
  83. package/lib/tools-response.ts +0 -12
  84. package/lib/tools.ts +4 -4
  85. package/lib/trace-projection.ts +33 -10
  86. package/package.json +1 -1
  87. package/scripts/locker.mjs +9 -16
  88. package/scripts/music-player.mjs +7 -13
  89. package/scripts/release-gates.mjs +33 -2
  90. package/scripts/validate-recipe.mjs +5 -4
  91. package/skills/actors/SKILL.md +12 -7
  92. package/skills/swarm/SKILL.md +0 -2
  93. package/docs/actors-deep-reference.md +0 -108
  94. package/docs/component-recipes.md +0 -45
  95. package/docs/task-first-recipes.md +0 -261
package/README.md CHANGED
@@ -53,8 +53,10 @@ Send one exact Control:
53
53
  Run targets accept only actions declared by the captured Recipe. Runtime targets accept only reserved review actions:
54
54
 
55
55
  ```text
56
- message target=runtime action=review.retry
57
- message target=runtime action=review.reset
56
+ message target=runtime action=review.retry input={"scope":"draft"}
57
+ message target=runtime action=review.retry input={"scope":"tool"}
58
+ message target=runtime action=review.reset input={"scope":"draft"}
59
+ message target=runtime action=review.reset input={"scope":"tool"}
58
60
  ```
59
61
 
60
62
  Lifecycle `kill` remains runtime-owned rather than Recipe-declared.
@@ -67,9 +69,9 @@ Inspect one exact management target:
67
69
  inspect target=run:test view=recipe
68
70
  inspect target=run:test view=trace source=lifecycle lines=40
69
71
  inspect target=run:test view=control
70
- inspect target=runtime
71
- inspect target=recipes
72
- inspect target=tool:my_tool
72
+ inspect target=runtime view=status
73
+ inspect target=recipes view=status
74
+ inspect target=tool:my_tool view=status
73
75
  ```
74
76
 
75
77
  A Run exposes exactly `recipe`, `trace`, and `control` views.
@@ -119,7 +121,7 @@ String command-template leaves execute directly without shell interpretation. Us
119
121
  }
120
122
  ```
121
123
 
122
- Trace fields are exact: `id`, `ts`, `kind`, and optional `summary`, `data`, `level`, `attention`. Address, sender, recipient, reply, and routing fields fail validation.
124
+ Trace fields are exact: `id`, `ts`, `kind`, and optional `summary`, `data`, `level`, `attention`. Address, sender, recipient, reply, and routing fields fail validation. The canonical append authority validates and size-checks under a cross-process mutation lock before one append-only JSONL write; first-party scripts never append this file directly.
123
125
 
124
126
  Use `attention: "notify"` for visible status and `attention: "followup"` only when the coordinator needs semantic follow-up context. Store large evidence in artifacts or bounded execution captures.
125
127
 
@@ -138,7 +140,7 @@ Long-lived services publish `control-endpoint.json` only when ready:
138
140
  }
139
141
  ```
140
142
 
141
- Supported transports are Unix FIFO and Windows named pipe. FIFO wire documents must fit the portable 512-byte atomic-write bound; larger Control input remains available to named-pipe services. Delivery revalidates owner, generation, state, and process identity under the canonical lifecycle lock.
143
+ Supported transports are Unix FIFO and Windows named pipe. Every actor-local Control uses the same portable envelope: action is at most 64 lowercase ASCII characters, serialized JSON input is at most 380 bytes, and the newline-terminated wire record is at most 512 bytes. Put larger data in a declared artifact/path and send only its bounded reference or instruction through Control. Delivery revalidates owner, generation, state, and process identity under the canonical lifecycle lock.
142
144
 
143
145
  ## Run State
144
146
 
package/dist/index.js CHANGED
@@ -106,7 +106,7 @@ export default function toolRegistryExtension(pi) {
106
106
  configPath: Paths.EXTENSION_RUNTIME_PATHS.configPath,
107
107
  getActiveTools: () => pi.getActiveTools(),
108
108
  getRuntimeTool: (name) => Tools.resolveActiveRuntimeTool(name, runtime.getTools(), (activeName) => actorToolDefinitions.get(activeName)),
109
- handleRuntimeMessage: automaticReview.handleMessage,
109
+ handleRuntimeControl: automaticReview.handleControl,
110
110
  registryRuntime: runtime,
111
111
  setActiveTools: (toolNames) => pi.setActiveTools(toolNames),
112
112
  }).map(withCurrentThinkingContext));
@@ -7,6 +7,7 @@ import { type CurrentPolicyProvenance } from "./model-context.ts";
7
7
  import * as RecipesReferences from "./recipes-references.ts";
8
8
  import { type RunArtifactDeclaration } from "./runs-artifacts.ts";
9
9
  import { type RunProcessIdentity } from "./runs-process.ts";
10
+ import * as RuntimeIdentity from "./runtime-identity.ts";
10
11
  import * as RunsIndex from "./runs-index.ts";
11
12
  import * as RunsParentTeardown from "./runs-parent-teardown.ts";
12
13
  import { type DeliverRunControlOptions, type DeliverRunControlRequest } from "./runs-control-delivery.ts";
@@ -77,7 +78,7 @@ export interface AsyncRunMeta {
77
78
  run: string;
78
79
  run_instance_id: string;
79
80
  state_dir: string;
80
- state_schema: "run-kernel-v1";
81
+ state_schema: typeof RuntimeIdentity.RUN_STATE_SCHEMA;
81
82
  status: AsyncRunStatus;
82
83
  tool?: string;
83
84
  template: CommandTemplateValue;
@@ -18,6 +18,7 @@ import { buildTerminalProgress, markTerminalHandled, signalOwnedRunProcess, } fr
18
18
  import { claimRunStateDirectory } from "./runs-ownership.js";
19
19
  import { appendRunRetentionEvidence, archiveTerminalRun, pruneTerminalRun, } from "./runs-retention.js";
20
20
  import { captureRunProcessIdentity, verifyRunProcessIdentity, } from "./runs-process.js";
21
+ import * as RuntimeIdentity from "./runtime-identity.js";
21
22
  import * as RunsStart from "./runs-start.js";
22
23
  import { appendRunTraceEvent } from "./runs-trace.js";
23
24
  import * as RunsIndex from "./runs-index.js";
@@ -236,7 +237,21 @@ export function startRun(params, cwd) {
236
237
  const resolved = resolveRunTemplate(startParams);
237
238
  const run = safeRunId(startParams.run_id);
238
239
  const stateDir = resolveStateDir(startParams, run);
240
+ const recipeFile = startParams.file
241
+ ? resolveRecipeFile(startParams.file)
242
+ : undefined;
243
+ const packagedRecipeRoot = resolve(Paths.getPackagedRecipeRoot());
244
+ const recipeRelation = recipeFile
245
+ ? relative(packagedRecipeRoot, recipeFile)
246
+ : undefined;
247
+ const packagedRepo = startParams.defaults?.repo === "~/.pi/agent/extensions/pi-actors" &&
248
+ recipeRelation &&
249
+ !recipeRelation.startsWith("..") &&
250
+ !isAbsolute(recipeRelation)
251
+ ? dirname(packagedRecipeRoot)
252
+ : undefined;
239
253
  const values = {
254
+ ...(packagedRepo ? { repo: packagedRepo } : {}),
240
255
  ...(startParams.values || {}),
241
256
  run_id: run,
242
257
  state_dir: stateDir,
@@ -259,9 +274,6 @@ export function startRun(params, cwd) {
259
274
  prepareStateDirForStart(stateDir);
260
275
  const stdout = join(stateDir, "stdout.log");
261
276
  const stderr = join(stateDir, "stderr.log");
262
- const recipeFile = startParams.file
263
- ? resolveRecipeFile(startParams.file)
264
- : undefined;
265
277
  const recipe = startParams.name || getRunIdFromFile(recipeFile);
266
278
  const includeActorRecipeContext = startParams.actor_context !== false &&
267
279
  startParams.actor_context !== "off";
@@ -298,7 +310,7 @@ export function startRun(params, cwd) {
298
310
  run,
299
311
  run_instance_id: randomUUID(),
300
312
  state_dir: stateDir,
301
- state_schema: "run-kernel-v1",
313
+ state_schema: RuntimeIdentity.RUN_STATE_SCHEMA,
302
314
  status: "running",
303
315
  ...(startParams.tool ? { tool: startParams.tool } : {}),
304
316
  template: resolved.template,
@@ -6,7 +6,7 @@
6
6
  import type * as Pi from "./pi.ts";
7
7
  export interface AutomaticReviewRuntime {
8
8
  close(): void;
9
- handleMessage(type: string, body: unknown): Record<string, unknown>;
9
+ handleControl(action: string, input: unknown): Record<string, unknown>;
10
10
  schedule(): void;
11
11
  start(ctx: Pi.ExtensionContext): void;
12
12
  }
@@ -26,14 +26,14 @@ export function createAutomaticReviewRuntime(deps) {
26
26
  });
27
27
  return {
28
28
  close,
29
- handleMessage(type, body) {
30
- if (type !== "review.retry" && type !== "review.reset") {
31
- throw new Error("tool:pi-actors accepts review.retry or review.reset messages.");
29
+ handleControl(action, input) {
30
+ if (action !== "review.retry" && action !== "review.reset") {
31
+ throw new Error("runtime accepts review.retry or review.reset Controls.");
32
32
  }
33
- if (type === "review.retry" && !Paths.isAutomaticRecipeReviewEnabled()) {
33
+ if (action === "review.retry" && !Paths.isAutomaticRecipeReviewEnabled()) {
34
34
  throw new Error("Automatic recipe review is disabled by PI_ACTORS_AUTOMATIC_REVIEW.");
35
35
  }
36
- return ReviewControl.controlAutomaticReview(type, ReviewControl.parseAutomaticReviewScope(body), {
36
+ return ReviewControl.controlAutomaticReview(action, ReviewControl.parseAutomaticReviewScope(input), {
37
37
  scheduleDraft: () => draftScheduler?.schedule(),
38
38
  scheduleTool: () => toolScheduler?.schedule(),
39
39
  });
@@ -74,6 +74,8 @@ export declare function getCommandTemplateRiskLabels(config: CommandTemplateConf
74
74
  export declare function getCommandTemplateDefaults(config: CommandTemplateConfig | undefined): Record<string, string>;
75
75
  export declare function splitCommandTemplate(input: string): string[];
76
76
  export declare function expandCommandTemplateExecutable(command: string, cwd: string): string;
77
+ export type CommandTemplateExecutableResolver = (name: string) => string | undefined;
78
+ export declare function resolveCommandTemplateScriptInvocation(invocation: CommandTemplateInvocation, resolveExecutable?: CommandTemplateExecutableResolver): CommandTemplateInvocation;
77
79
  export declare function shouldRunCommandTemplateNode(value: boolean | string | undefined, values: Record<string, unknown>): boolean;
78
80
  export declare function substituteCommandTemplateToken(token: string, values: Record<string, unknown>, missingLabel?: string, depth?: number): string;
79
81
  export declare function execCommandTemplate(command: string, args: string[], options?: CommandTemplateExecOptions): Promise<CommandTemplateExecResult>;
@@ -4,9 +4,9 @@
4
4
  * Owns portable command-template parsing, expansion, risk checks, retries, timeouts, and direct execution.
5
5
  */
6
6
  import { spawn } from "node:child_process";
7
- import { appendFileSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
7
+ import { accessSync, appendFileSync, constants, mkdirSync, mkdtempSync, statSync, writeFileSync } from "node:fs";
8
8
  import { homedir, tmpdir } from "node:os";
9
- import { isAbsolute, join, resolve as resolvePath } from "node:path";
9
+ import { basename, delimiter, extname, isAbsolute, join, resolve as resolvePath } from "node:path";
10
10
  const DEFAULT_COMMAND_CAPTURE_LIMIT_BYTES = 1024 * 1024;
11
11
  const COMMAND_TEMPLATE_RISK_LABEL_ORDER = [
12
12
  "risk.shell",
@@ -135,7 +135,7 @@ function getLeafCommandTemplateRiskLabels(config) {
135
135
  const command = getExecutableName(parts[0]);
136
136
  const args = parts.slice(1);
137
137
  const labels = new Set();
138
- if (["bash", "sh", "zsh", "fish"].includes(command)) {
138
+ if (["bash", "sh", "zsh", "fish"].includes(command) || command.endsWith(".sh")) {
139
139
  labels.add("risk.shell");
140
140
  if (hasAnyFlag(args, ["-c"]))
141
141
  labels.add("risk.eval");
@@ -393,6 +393,40 @@ export function expandCommandTemplateExecutable(command, cwd) {
393
393
  return resolvePath(cwd, command);
394
394
  return command;
395
395
  }
396
+ function resolveExecutableOnPath(name) {
397
+ if (name === "node" && /^node(?:\.exe)?$/i.test(basename(process.execPath)))
398
+ return process.execPath;
399
+ const pathValue = Object.entries(process.env)
400
+ .find(([key]) => key.toLowerCase() === "path")?.[1] ?? "";
401
+ const suffixes = process.platform === "win32" ? [".exe", ".com"] : [""];
402
+ for (const entry of pathValue.split(delimiter).filter(Boolean)) {
403
+ const directory = entry.replace(/^"|"$/g, "");
404
+ for (const suffix of suffixes) {
405
+ const candidate = join(directory, `${name}${suffix}`);
406
+ try {
407
+ accessSync(candidate, constants.X_OK);
408
+ if (statSync(candidate).isFile())
409
+ return candidate;
410
+ }
411
+ catch { }
412
+ }
413
+ }
414
+ return undefined;
415
+ }
416
+ export function resolveCommandTemplateScriptInvocation(invocation, resolveExecutable = resolveExecutableOnPath) {
417
+ const extension = extname(invocation.command).toLowerCase();
418
+ const interpreters = extension === ".js" || extension === ".mjs"
419
+ ? [["node"], ["bun"], ["deno", "run"]]
420
+ : extension === ".sh" ? [["bash"]] : [];
421
+ if (interpreters.length === 0)
422
+ return invocation;
423
+ for (const [name, ...prefix] of interpreters) {
424
+ const executable = resolveExecutable(name);
425
+ if (executable)
426
+ return { command: executable, args: [...prefix, invocation.command, ...invocation.args] };
427
+ }
428
+ throw new Error(`No interpreter available for command-template ${extension} script; tried ${interpreters.map(([name]) => name).join(", ")}.`);
429
+ }
396
430
  function evaluateCommandTemplateExpression(expression, values) {
397
431
  let index = 0;
398
432
  const source = expression.replace(/\s+/g, "");
@@ -766,5 +800,5 @@ export function buildCommandTemplateInvocation(config, values, cwd, options = {}
766
800
  .slice(1)
767
801
  .map((part) => substituteCommandTemplateToken(part, resolvedValues, options.missingLabel))
768
802
  .filter((part) => part !== "");
769
- return { command, args };
803
+ return resolveCommandTemplateScriptInvocation({ command, args });
770
804
  }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Model-facing Run Control projection.
3
+ * Zones: structured redaction, bounded Control input/error, decision-useful evidence
4
+ * Owns safe Control reads; durable journals and lifecycle transitions remain in runs-controls.
5
+ */
6
+ import type { RunControlRecord, RunControlStatus } from "./runs-controls.ts";
7
+ export interface ProjectedRunControl {
8
+ action: string;
9
+ claimed_at?: string;
10
+ delivered_at?: string;
11
+ error?: string;
12
+ failed_at?: string;
13
+ handled_at?: string;
14
+ id: string;
15
+ input?: unknown;
16
+ queued_at: string;
17
+ run_instance_id: string;
18
+ status: RunControlStatus;
19
+ }
20
+ export declare function projectRunControl(control: RunControlRecord): ProjectedRunControl;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Model-facing Run Control projection.
3
+ * Zones: structured redaction, bounded Control input/error, decision-useful evidence
4
+ * Owns safe Control reads; durable journals and lifecycle transitions remain in runs-controls.
5
+ */
6
+ import * as Limits from "./limits.js";
7
+ import * as SessionEvidence from "./session-evidence.js";
8
+ const OPTIONAL_TIMESTAMP_FIELDS = [
9
+ "claimed_at",
10
+ "delivered_at",
11
+ "failed_at",
12
+ "handled_at",
13
+ ];
14
+ function boundedRedactedString(value, maxChars) {
15
+ const redacted = SessionEvidence.redactSessionEvidenceValue(value, maxChars);
16
+ return typeof redacted === "string"
17
+ ? redacted
18
+ : String(redacted).slice(0, maxChars);
19
+ }
20
+ function boundedRedactedValue(value) {
21
+ const redacted = SessionEvidence.redactSessionEvidenceValue(value, Limits.INSPECTOR_BODY_PREVIEW_CHARS);
22
+ let serialized;
23
+ try {
24
+ serialized = JSON.stringify(redacted);
25
+ }
26
+ catch {
27
+ return "[UNSERIALIZABLE]";
28
+ }
29
+ if (serialized === undefined)
30
+ return "[UNSERIALIZABLE]";
31
+ if (serialized.length <= Limits.INSPECTOR_BODY_PREVIEW_CHARS)
32
+ return redacted;
33
+ let preview = serialized.slice(0, Limits.INSPECTOR_BODY_PREVIEW_CHARS);
34
+ let bounded = { preview: `${preview}…`, truncated: true };
35
+ while (preview &&
36
+ JSON.stringify(bounded).length > Limits.INSPECTOR_BODY_PREVIEW_CHARS) {
37
+ const excess = JSON.stringify(bounded).length - Limits.INSPECTOR_BODY_PREVIEW_CHARS;
38
+ preview = preview.slice(0, Math.max(0, preview.length - Math.max(1, excess)));
39
+ bounded = { preview: `${preview}…`, truncated: true };
40
+ }
41
+ return bounded;
42
+ }
43
+ export function projectRunControl(control) {
44
+ const timestamps = Object.fromEntries(OPTIONAL_TIMESTAMP_FIELDS.flatMap((field) => {
45
+ const value = control[field];
46
+ return typeof value === "string"
47
+ ? [[field, boundedRedactedString(value, Limits.COMPACT_PREVIEW_CHARS)]]
48
+ : [];
49
+ }));
50
+ return {
51
+ action: boundedRedactedString(control.action, Limits.COMPACT_PREVIEW_CHARS),
52
+ ...timestamps,
53
+ ...(typeof control.error === "string"
54
+ ? {
55
+ error: boundedRedactedString(control.error, Limits.INSPECTOR_BODY_PREVIEW_CHARS),
56
+ }
57
+ : {}),
58
+ id: boundedRedactedString(control.id, Limits.COMPACT_PREVIEW_CHARS),
59
+ ...(control.input !== undefined
60
+ ? { input: boundedRedactedValue(control.input) }
61
+ : {}),
62
+ queued_at: boundedRedactedString(control.queued_at, Limits.COMPACT_PREVIEW_CHARS),
63
+ run_instance_id: boundedRedactedString(control.run_instance_id, Limits.COMPACT_PREVIEW_CHARS),
64
+ status: boundedRedactedString(control.status, Limits.COMPACT_PREVIEW_CHARS),
65
+ };
66
+ }
@@ -9,4 +9,7 @@ export interface ControlRequest {
9
9
  input?: unknown;
10
10
  verbose?: boolean;
11
11
  }
12
+ export declare function isControlAction(value: string): boolean;
13
+ export declare function normalizeControlAction(value: unknown): string;
14
+ export declare function normalizeControlInput(input: unknown): unknown;
12
15
  export declare function normalizeControlRequest(input: unknown): ControlRequest;
@@ -17,7 +17,23 @@ const REMOVED_FIELDS = new Set([
17
17
  "to",
18
18
  "type",
19
19
  ]);
20
- function serializedInputBytes(input) {
20
+ export function isControlAction(value) {
21
+ return ACTION_PATTERN.test(value);
22
+ }
23
+ export function normalizeControlAction(value) {
24
+ if (typeof value !== "string" || !value.trim()) {
25
+ throw new Error("control.action is required");
26
+ }
27
+ const action = value.trim();
28
+ if (!isControlAction(action)) {
29
+ throw new Error(`invalid control action: ${action}`);
30
+ }
31
+ if (action.length > Limits.CONTROL_ACTION_MAX_LENGTH) {
32
+ throw new Error(`control.action exceeds ${Limits.CONTROL_ACTION_MAX_LENGTH} ASCII characters`);
33
+ }
34
+ return action;
35
+ }
36
+ export function normalizeControlInput(input) {
21
37
  let serialized;
22
38
  try {
23
39
  serialized = JSON.stringify(input);
@@ -28,7 +44,11 @@ function serializedInputBytes(input) {
28
44
  if (serialized === undefined) {
29
45
  throw new Error("control.input must be JSON-serializable");
30
46
  }
31
- return Buffer.byteLength(serialized);
47
+ const bytes = Buffer.byteLength(serialized);
48
+ if (bytes > Limits.CONTROL_INPUT_MAX_BYTES) {
49
+ throw new Error(`control.input exceeds ${Limits.CONTROL_INPUT_MAX_BYTES} serialized bytes`);
50
+ }
51
+ return JSON.parse(serialized);
32
52
  }
33
53
  function normalizeTarget(value) {
34
54
  if (typeof value !== "string" || !value.trim()) {
@@ -61,24 +81,17 @@ export function normalizeControlRequest(input) {
61
81
  throw new Error(`unsupported control fields: ${unknown.sort().join(", ")}`);
62
82
  }
63
83
  const target = normalizeTarget(record.target);
64
- if (typeof record.action !== "string" || !record.action.trim()) {
65
- throw new Error("control.action is required");
66
- }
67
- const action = record.action.trim();
68
- if (!ACTION_PATTERN.test(action)) {
69
- throw new Error(`invalid control action: ${action}`);
70
- }
84
+ const action = normalizeControlAction(record.action);
71
85
  if (record.verbose !== undefined && typeof record.verbose !== "boolean") {
72
86
  throw new Error("control.verbose must be a boolean");
73
87
  }
74
- if (record.input !== undefined &&
75
- serializedInputBytes(record.input) > Limits.CONTROL_INPUT_MAX_BYTES) {
76
- throw new Error(`control.input exceeds ${Limits.CONTROL_INPUT_MAX_BYTES} bytes`);
77
- }
88
+ const normalizedInput = record.input === undefined
89
+ ? undefined
90
+ : normalizeControlInput(record.input);
78
91
  return {
79
92
  target,
80
93
  action,
81
- ...(record.input !== undefined ? { input: record.input } : {}),
94
+ ...(normalizedInput !== undefined ? { input: normalizedInput } : {}),
82
95
  ...(record.verbose !== undefined ? { verbose: record.verbose } : {}),
83
96
  };
84
97
  }
@@ -391,7 +391,7 @@ export function createDraftSleepScheduler(deps) {
391
391
  ...existing,
392
392
  failedStage: processed.stage ?? "result_processing",
393
393
  lastError: processed.error ?? "automatic draft review processing failed",
394
- nextAction: "message to=tool:pi-actors type=review.retry body={\"scope\":\"draft\"}",
394
+ nextAction: "message target=runtime action=review.retry input={\"scope\":\"draft\"}",
395
395
  phase: "processing_failed",
396
396
  processingAttempts: (existing.processingAttempts ?? 0) + 1,
397
397
  updatedAt: now().toISOString(),
@@ -402,7 +402,7 @@ export function createDraftSleepScheduler(deps) {
402
402
  ...existing,
403
403
  failedStage: processed.stage ?? "review_result",
404
404
  lastError: processed.error ?? "automatic draft reviewer failed",
405
- nextAction: "message to=tool:pi-actors type=review.retry body={\"scope\":\"draft\"}",
405
+ nextAction: "message target=runtime action=review.retry input={\"scope\":\"draft\"}",
406
406
  phase: "failed",
407
407
  runId: undefined,
408
408
  updatedAt: now().toISOString(),
@@ -444,7 +444,7 @@ export function createDraftSleepScheduler(deps) {
444
444
  attempts: (prior?.batchId === batch.batchId ? prior.attempts : 0) + 1,
445
445
  failedStage: "review_launch",
446
446
  lastError: error instanceof Error ? error.message : String(error),
447
- nextAction: "message to=tool:pi-actors type=review.retry body={\"scope\":\"draft\"}",
447
+ nextAction: "message target=runtime action=review.retry input={\"scope\":\"draft\"}",
448
448
  batchId: batch.batchId,
449
449
  inputPath: batch.inputPath,
450
450
  phase: "failed",
@@ -5,6 +5,7 @@
5
5
  */
6
6
  export declare function mutationLockPath(path: string): string;
7
7
  export interface FileMutationLockOptions {
8
+ onBeforeLockPublish?(): void;
8
9
  onBeforeReclaimRemove?(): void;
9
10
  onContention?(): void;
10
11
  onRemovalContention?(): void;
@@ -3,11 +3,12 @@
3
3
  * Zones: file persistence, atomic writes, runtime state support
4
4
  * Owns generic durable JSON file writes shared by registry config and async run state.
5
5
  */
6
+ import { spawnSync } from "node:child_process";
6
7
  import { createHash, randomUUID } from "node:crypto";
7
- import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
8
+ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
8
9
  import { tmpdir } from "node:os";
9
10
  import { basename, dirname, join, parse, resolve } from "node:path";
10
- const FILE_MUTATION_LOCK_TIMEOUT_MS = 5000;
11
+ const FILE_MUTATION_LOCK_TIMEOUT_MS = process.platform === "win32" ? 15000 : 5000;
11
12
  const FILE_MUTATION_LOCK_STALE_MS = 30000;
12
13
  const FILE_MUTATION_LOCK_ROOT = join(tmpdir(), "pi-actors-file-locks");
13
14
  function canonicalMutationPath(path) {
@@ -42,6 +43,20 @@ export function mutationLockPath(path) {
42
43
  .digest("hex");
43
44
  return join(FILE_MUTATION_LOCK_ROOT, `${key}.lock`);
44
45
  }
46
+ function isZombieProcess(pid) {
47
+ if (process.platform === "linux")
48
+ try {
49
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
50
+ return stat.slice(stat.lastIndexOf(")") + 2).startsWith("Z ");
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ if (process.platform !== "darwin")
56
+ return false;
57
+ const result = spawnSync("ps", ["-p", String(pid), "-o", "stat="], { encoding: "utf8" });
58
+ return result.status === 0 && result.stdout.trim().startsWith("Z");
59
+ }
45
60
  function lockOwnerStatus(lockPath) {
46
61
  try {
47
62
  const owner = JSON.parse(readFileSync(join(lockPath, "owner.json"), "utf8"));
@@ -50,6 +65,8 @@ function lockOwnerStatus(lockPath) {
50
65
  return "unknown";
51
66
  try {
52
67
  process.kill(pid, 0);
68
+ if (Date.now() - statSync(lockPath).mtimeMs > 50 && isZombieProcess(pid))
69
+ return "dead";
53
70
  return "alive";
54
71
  }
55
72
  catch (error) {
@@ -71,6 +88,46 @@ function readLockToken(lockPath) {
71
88
  return undefined;
72
89
  }
73
90
  }
91
+ function prepareLockBoundary(lockPath, token, onBeforePublish) {
92
+ const pendingPath = `${lockPath}.${process.pid}.${token}.pending`;
93
+ try {
94
+ mkdirSync(pendingPath);
95
+ writeFileSync(join(pendingPath, "owner.json"), `${JSON.stringify({ pid: process.pid, token, acquired_at: new Date().toISOString() })}\n`, "utf8");
96
+ onBeforePublish?.();
97
+ return pendingPath;
98
+ }
99
+ catch (error) {
100
+ try {
101
+ rmSync(pendingPath, { recursive: true, force: true });
102
+ }
103
+ catch { }
104
+ throw error;
105
+ }
106
+ }
107
+ function removeLockBoundary(lockPath, token) {
108
+ if (readLockToken(lockPath) !== token)
109
+ return false;
110
+ if (process.platform === "win32")
111
+ try {
112
+ rmSync(lockPath, { recursive: true, force: true, maxRetries: 20, retryDelay: 5 });
113
+ return true;
114
+ }
115
+ catch {
116
+ return false;
117
+ }
118
+ const removingPath = `${lockPath}.${process.pid}.${randomUUID()}.removing`;
119
+ try {
120
+ renameSync(lockPath, removingPath);
121
+ }
122
+ catch {
123
+ return false;
124
+ }
125
+ try {
126
+ rmSync(removingPath, { recursive: true, force: true });
127
+ }
128
+ catch { }
129
+ return true;
130
+ }
74
131
  function tryReclaimRemovalBoundary(reclaimPath) {
75
132
  if (!existsSync(reclaimPath))
76
133
  return;
@@ -81,7 +138,7 @@ function tryReclaimRemovalBoundary(reclaimPath) {
81
138
  if ((ownerStatus === "dead" ||
82
139
  (ownerStatus === "unknown" && age > FILE_MUTATION_LOCK_STALE_MS)) &&
83
140
  readLockToken(reclaimPath) === inspectedToken) {
84
- rmSync(reclaimPath, { recursive: true, force: true });
141
+ removeLockBoundary(reclaimPath, inspectedToken);
85
142
  }
86
143
  }
87
144
  catch {
@@ -91,28 +148,28 @@ function tryReclaimRemovalBoundary(reclaimPath) {
91
148
  function withRemovalBoundary(lockPath, action) {
92
149
  const reclaimPath = `${lockPath}.reclaim`;
93
150
  const token = randomUUID();
151
+ const pendingPath = prepareLockBoundary(reclaimPath, token);
94
152
  try {
95
- mkdirSync(reclaimPath);
96
153
  try {
97
- writeFileSync(join(reclaimPath, "owner.json"), `${JSON.stringify({ pid: process.pid, token, acquired_at: new Date().toISOString() })}\n`, "utf8");
154
+ renameSync(pendingPath, reclaimPath);
98
155
  }
99
- catch (error) {
100
- rmSync(reclaimPath, { recursive: true, force: true });
101
- throw error;
156
+ catch {
157
+ tryReclaimRemovalBoundary(reclaimPath);
158
+ return false;
159
+ }
160
+ try {
161
+ action();
162
+ return true;
163
+ }
164
+ finally {
165
+ removeLockBoundary(reclaimPath, token);
102
166
  }
103
- }
104
- catch {
105
- tryReclaimRemovalBoundary(reclaimPath);
106
- return false;
107
- }
108
- try {
109
- action();
110
- return true;
111
167
  }
112
168
  finally {
113
- if (readLockToken(reclaimPath) === token) {
114
- rmSync(reclaimPath, { recursive: true, force: true });
169
+ try {
170
+ rmSync(pendingPath, { recursive: true, force: true });
115
171
  }
172
+ catch { }
116
173
  }
117
174
  }
118
175
  function tryReclaimMutationLock(lockPath, options) {
@@ -129,8 +186,7 @@ function tryReclaimMutationLock(lockPath, options) {
129
186
  (ownerStatus === "unknown" && age > FILE_MUTATION_LOCK_STALE_MS)) &&
130
187
  readLockToken(lockPath) === inspectedToken) {
131
188
  options.onBeforeReclaimRemove?.();
132
- rmSync(lockPath, { recursive: true, force: true });
133
- reclaimed = true;
189
+ reclaimed = removeLockBoundary(lockPath, inspectedToken);
134
190
  }
135
191
  })) {
136
192
  return false;
@@ -142,32 +198,34 @@ export function acquireFileMutationLock(path, options = {}) {
142
198
  const lockPath = mutationLockPath(path);
143
199
  const deadline = Date.now() + FILE_MUTATION_LOCK_TIMEOUT_MS;
144
200
  const token = randomUUID();
201
+ const pendingPath = prepareLockBoundary(lockPath, token, options.onBeforeLockPublish);
145
202
  let contentionReported = false;
146
- for (;;) {
147
- try {
148
- mkdirSync(lockPath);
203
+ try {
204
+ for (;;) {
149
205
  try {
150
- writeFileSync(join(lockPath, "owner.json"), `${JSON.stringify({ pid: process.pid, token, acquired_at: new Date().toISOString() })}\n`, "utf8");
206
+ renameSync(pendingPath, lockPath);
207
+ break;
151
208
  }
152
209
  catch (error) {
153
- rmSync(lockPath, { recursive: true, force: true });
154
- throw error;
210
+ if (!contentionReported) {
211
+ contentionReported = true;
212
+ options.onContention?.();
213
+ }
214
+ tryReclaimMutationLock(lockPath, options);
215
+ if (Date.now() >= deadline) {
216
+ throw new Error(`Timed out waiting for file mutation lock: ${canonicalMutationPath(path)}`, {
217
+ cause: error,
218
+ });
219
+ }
220
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
155
221
  }
156
- break;
157
222
  }
158
- catch (error) {
159
- if (!contentionReported) {
160
- contentionReported = true;
161
- options.onContention?.();
162
- }
163
- tryReclaimMutationLock(lockPath, options);
164
- if (Date.now() >= deadline) {
165
- throw new Error(`Timed out waiting for file mutation lock: ${canonicalMutationPath(path)}`, {
166
- cause: error,
167
- });
168
- }
169
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
223
+ }
224
+ finally {
225
+ try {
226
+ rmSync(pendingPath, { recursive: true, force: true });
170
227
  }
228
+ catch { }
171
229
  }
172
230
  let released = false;
173
231
  return () => {
@@ -177,9 +235,7 @@ export function acquireFileMutationLock(path, options = {}) {
177
235
  const deadline = Date.now() + FILE_MUTATION_LOCK_TIMEOUT_MS;
178
236
  let removalContentionReported = false;
179
237
  while (!withRemovalBoundary(lockPath, () => {
180
- if (readLockToken(lockPath) === token) {
181
- rmSync(lockPath, { recursive: true, force: true });
182
- }
238
+ removeLockBoundary(lockPath, token);
183
239
  })) {
184
240
  if (!removalContentionReported) {
185
241
  removalContentionReported = true;