@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
@@ -5,9 +5,9 @@
5
5
  */
6
6
 
7
7
  import { spawn } from "node:child_process";
8
- import { appendFileSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
8
+ import { accessSync, appendFileSync, constants, mkdirSync, mkdtempSync, statSync, writeFileSync } from "node:fs";
9
9
  import { homedir, tmpdir } from "node:os";
10
- import { isAbsolute, join, resolve as resolvePath } from "node:path";
10
+ import { basename, delimiter, extname, isAbsolute, join, resolve as resolvePath } from "node:path";
11
11
 
12
12
  export type CommandTemplateFailureScope = "continue" | "branch" | "root";
13
13
 
@@ -259,7 +259,7 @@ function getLeafCommandTemplateRiskLabels(
259
259
  const command = getExecutableName(parts[0]);
260
260
  const args = parts.slice(1);
261
261
  const labels = new Set<CommandTemplateRiskLabel>();
262
- if (["bash", "sh", "zsh", "fish"].includes(command)) {
262
+ if (["bash", "sh", "zsh", "fish"].includes(command) || command.endsWith(".sh")) {
263
263
  labels.add("risk.shell");
264
264
  if (hasAnyFlag(args, ["-c"])) labels.add("risk.eval");
265
265
  }
@@ -602,6 +602,46 @@ export function expandCommandTemplateExecutable(
602
602
  return command;
603
603
  }
604
604
 
605
+ export type CommandTemplateExecutableResolver = (name: string) => string | undefined;
606
+
607
+ function resolveExecutableOnPath(name: string): string | undefined {
608
+ if (name === "node" && /^node(?:\.exe)?$/i.test(basename(process.execPath)))
609
+ return process.execPath;
610
+ const pathValue = Object.entries(process.env)
611
+ .find(([key]) => key.toLowerCase() === "path")?.[1] ?? "";
612
+ const suffixes = process.platform === "win32" ? [".exe", ".com"] : [""];
613
+ for (const entry of pathValue.split(delimiter).filter(Boolean)) {
614
+ const directory = entry.replace(/^"|"$/g, "");
615
+ for (const suffix of suffixes) {
616
+ const candidate = join(directory, `${name}${suffix}`);
617
+ try {
618
+ accessSync(candidate, constants.X_OK);
619
+ if (statSync(candidate).isFile()) return candidate;
620
+ } catch {}
621
+ }
622
+ }
623
+ return undefined;
624
+ }
625
+
626
+ export function resolveCommandTemplateScriptInvocation(
627
+ invocation: CommandTemplateInvocation,
628
+ resolveExecutable: CommandTemplateExecutableResolver = resolveExecutableOnPath,
629
+ ): CommandTemplateInvocation {
630
+ const extension = extname(invocation.command).toLowerCase();
631
+ const interpreters = extension === ".js" || extension === ".mjs"
632
+ ? [["node"], ["bun"], ["deno", "run"]]
633
+ : extension === ".sh" ? [["bash"]] : [];
634
+ if (interpreters.length === 0) return invocation;
635
+ for (const [name, ...prefix] of interpreters) {
636
+ const executable = resolveExecutable(name);
637
+ if (executable)
638
+ return { command: executable, args: [...prefix, invocation.command, ...invocation.args] };
639
+ }
640
+ throw new Error(
641
+ `No interpreter available for command-template ${extension} script; tried ${interpreters.map(([name]) => name).join(", ")}.`,
642
+ );
643
+ }
644
+
605
645
  function evaluateCommandTemplateExpression(
606
646
  expression: string,
607
647
  values: Record<string, unknown>,
@@ -1072,5 +1112,5 @@ export function buildCommandTemplateInvocation(
1072
1112
  ),
1073
1113
  )
1074
1114
  .filter((part) => part !== "");
1075
- return { command, args };
1115
+ return resolveCommandTemplateScriptInvocation({ command, args });
1076
1116
  }
@@ -0,0 +1,105 @@
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
+
7
+ import * as Limits from "./limits.ts";
8
+ import type { RunControlRecord, RunControlStatus } from "./runs-controls.ts";
9
+ import * as SessionEvidence from "./session-evidence.ts";
10
+
11
+ export interface ProjectedRunControl {
12
+ action: string;
13
+ claimed_at?: string;
14
+ delivered_at?: string;
15
+ error?: string;
16
+ failed_at?: string;
17
+ handled_at?: string;
18
+ id: string;
19
+ input?: unknown;
20
+ queued_at: string;
21
+ run_instance_id: string;
22
+ status: RunControlStatus;
23
+ }
24
+
25
+ const OPTIONAL_TIMESTAMP_FIELDS = [
26
+ "claimed_at",
27
+ "delivered_at",
28
+ "failed_at",
29
+ "handled_at",
30
+ ] as const;
31
+ type OptionalTimestampField = (typeof OPTIONAL_TIMESTAMP_FIELDS)[number];
32
+
33
+ function boundedRedactedString(value: string, maxChars: number): string {
34
+ const redacted = SessionEvidence.redactSessionEvidenceValue(value, maxChars);
35
+ return typeof redacted === "string"
36
+ ? redacted
37
+ : String(redacted).slice(0, maxChars);
38
+ }
39
+
40
+ function boundedRedactedValue(value: unknown): unknown {
41
+ const redacted = SessionEvidence.redactSessionEvidenceValue(
42
+ value,
43
+ Limits.INSPECTOR_BODY_PREVIEW_CHARS,
44
+ );
45
+ let serialized: string | undefined;
46
+ try {
47
+ serialized = JSON.stringify(redacted);
48
+ } catch {
49
+ return "[UNSERIALIZABLE]";
50
+ }
51
+ if (serialized === undefined) return "[UNSERIALIZABLE]";
52
+ if (serialized.length <= Limits.INSPECTOR_BODY_PREVIEW_CHARS) return redacted;
53
+ let preview = serialized.slice(0, Limits.INSPECTOR_BODY_PREVIEW_CHARS);
54
+ let bounded = { preview: `${preview}…`, truncated: true };
55
+ while (
56
+ preview &&
57
+ JSON.stringify(bounded).length > Limits.INSPECTOR_BODY_PREVIEW_CHARS
58
+ ) {
59
+ const excess = JSON.stringify(bounded).length - Limits.INSPECTOR_BODY_PREVIEW_CHARS;
60
+ preview = preview.slice(0, Math.max(0, preview.length - Math.max(1, excess)));
61
+ bounded = { preview: `${preview}…`, truncated: true };
62
+ }
63
+ return bounded;
64
+ }
65
+
66
+ export function projectRunControl(
67
+ control: RunControlRecord,
68
+ ): ProjectedRunControl {
69
+ const timestamps = Object.fromEntries(
70
+ OPTIONAL_TIMESTAMP_FIELDS.flatMap((field) => {
71
+ const value = control[field];
72
+ return typeof value === "string"
73
+ ? [[field, boundedRedactedString(value, Limits.COMPACT_PREVIEW_CHARS)] as const]
74
+ : [];
75
+ }),
76
+ ) as Partial<Pick<ProjectedRunControl, OptionalTimestampField>>;
77
+ return {
78
+ action: boundedRedactedString(control.action, Limits.COMPACT_PREVIEW_CHARS),
79
+ ...timestamps,
80
+ ...(typeof control.error === "string"
81
+ ? {
82
+ error: boundedRedactedString(
83
+ control.error,
84
+ Limits.INSPECTOR_BODY_PREVIEW_CHARS,
85
+ ),
86
+ }
87
+ : {}),
88
+ id: boundedRedactedString(control.id, Limits.COMPACT_PREVIEW_CHARS),
89
+ ...(control.input !== undefined
90
+ ? { input: boundedRedactedValue(control.input) }
91
+ : {}),
92
+ queued_at: boundedRedactedString(
93
+ control.queued_at,
94
+ Limits.COMPACT_PREVIEW_CHARS,
95
+ ),
96
+ run_instance_id: boundedRedactedString(
97
+ control.run_instance_id,
98
+ Limits.COMPACT_PREVIEW_CHARS,
99
+ ),
100
+ status: boundedRedactedString(
101
+ control.status,
102
+ Limits.COMPACT_PREVIEW_CHARS,
103
+ ) as RunControlStatus,
104
+ };
105
+ }
package/lib/control.ts CHANGED
@@ -27,7 +27,27 @@ const REMOVED_FIELDS = new Set([
27
27
  "type",
28
28
  ]);
29
29
 
30
- function serializedInputBytes(input: unknown): number {
30
+ export function isControlAction(value: string): boolean {
31
+ return ACTION_PATTERN.test(value);
32
+ }
33
+
34
+ export function normalizeControlAction(value: unknown): string {
35
+ if (typeof value !== "string" || !value.trim()) {
36
+ throw new Error("control.action is required");
37
+ }
38
+ const action = value.trim();
39
+ if (!isControlAction(action)) {
40
+ throw new Error(`invalid control action: ${action}`);
41
+ }
42
+ if (action.length > Limits.CONTROL_ACTION_MAX_LENGTH) {
43
+ throw new Error(
44
+ `control.action exceeds ${Limits.CONTROL_ACTION_MAX_LENGTH} ASCII characters`,
45
+ );
46
+ }
47
+ return action;
48
+ }
49
+
50
+ export function normalizeControlInput(input: unknown): unknown {
31
51
  let serialized: string | undefined;
32
52
  try {
33
53
  serialized = JSON.stringify(input);
@@ -37,7 +57,13 @@ function serializedInputBytes(input: unknown): number {
37
57
  if (serialized === undefined) {
38
58
  throw new Error("control.input must be JSON-serializable");
39
59
  }
40
- return Buffer.byteLength(serialized);
60
+ const bytes = Buffer.byteLength(serialized);
61
+ if (bytes > Limits.CONTROL_INPUT_MAX_BYTES) {
62
+ throw new Error(
63
+ `control.input exceeds ${Limits.CONTROL_INPUT_MAX_BYTES} serialized bytes`,
64
+ );
65
+ }
66
+ return JSON.parse(serialized) as unknown;
41
67
  }
42
68
 
43
69
  function normalizeTarget(value: unknown): ControlRequest["target"] {
@@ -75,28 +101,17 @@ export function normalizeControlRequest(input: unknown): ControlRequest {
75
101
  throw new Error(`unsupported control fields: ${unknown.sort().join(", ")}`);
76
102
  }
77
103
  const target = normalizeTarget(record.target);
78
- if (typeof record.action !== "string" || !record.action.trim()) {
79
- throw new Error("control.action is required");
80
- }
81
- const action = record.action.trim();
82
- if (!ACTION_PATTERN.test(action)) {
83
- throw new Error(`invalid control action: ${action}`);
84
- }
104
+ const action = normalizeControlAction(record.action);
85
105
  if (record.verbose !== undefined && typeof record.verbose !== "boolean") {
86
106
  throw new Error("control.verbose must be a boolean");
87
107
  }
88
- if (
89
- record.input !== undefined &&
90
- serializedInputBytes(record.input) > Limits.CONTROL_INPUT_MAX_BYTES
91
- ) {
92
- throw new Error(
93
- `control.input exceeds ${Limits.CONTROL_INPUT_MAX_BYTES} bytes`,
94
- );
95
- }
108
+ const normalizedInput = record.input === undefined
109
+ ? undefined
110
+ : normalizeControlInput(record.input);
96
111
  return {
97
112
  target,
98
113
  action,
99
- ...(record.input !== undefined ? { input: record.input } : {}),
114
+ ...(normalizedInput !== undefined ? { input: normalizedInput } : {}),
100
115
  ...(record.verbose !== undefined ? { verbose: record.verbose } : {}),
101
116
  };
102
117
  }
@@ -498,7 +498,7 @@ export function createDraftSleepScheduler(
498
498
  ...existing,
499
499
  failedStage: processed.stage ?? "result_processing",
500
500
  lastError: processed.error ?? "automatic draft review processing failed",
501
- nextAction: "message to=tool:pi-actors type=review.retry body={\"scope\":\"draft\"}",
501
+ nextAction: "message target=runtime action=review.retry input={\"scope\":\"draft\"}",
502
502
  phase: "processing_failed",
503
503
  processingAttempts: (existing.processingAttempts ?? 0) + 1,
504
504
  updatedAt: now().toISOString(),
@@ -509,7 +509,7 @@ export function createDraftSleepScheduler(
509
509
  ...existing,
510
510
  failedStage: processed.stage ?? "review_result",
511
511
  lastError: processed.error ?? "automatic draft reviewer failed",
512
- nextAction: "message to=tool:pi-actors type=review.retry body={\"scope\":\"draft\"}",
512
+ nextAction: "message target=runtime action=review.retry input={\"scope\":\"draft\"}",
513
513
  phase: "failed",
514
514
  runId: undefined,
515
515
  updatedAt: now().toISOString(),
@@ -552,7 +552,7 @@ export function createDraftSleepScheduler(
552
552
  attempts: (prior?.batchId === batch.batchId ? prior.attempts : 0) + 1,
553
553
  failedStage: "review_launch",
554
554
  lastError: error instanceof Error ? error.message : String(error),
555
- nextAction: "message to=tool:pi-actors type=review.retry body={\"scope\":\"draft\"}",
555
+ nextAction: "message target=runtime action=review.retry input={\"scope\":\"draft\"}",
556
556
  batchId: batch.batchId,
557
557
  inputPath: batch.inputPath,
558
558
  phase: "failed",
package/lib/file-state.ts CHANGED
@@ -4,23 +4,13 @@
4
4
  * Owns generic durable JSON file writes shared by registry config and async run state.
5
5
  */
6
6
 
7
+ import { spawnSync } from "node:child_process";
7
8
  import { createHash, randomUUID } from "node:crypto";
8
- import {
9
- existsSync,
10
- lstatSync,
11
- mkdirSync,
12
- readFileSync,
13
- realpathSync,
14
- renameSync,
15
- rmSync,
16
- statSync,
17
- unlinkSync,
18
- writeFileSync,
19
- } from "node:fs";
9
+ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
20
10
  import { tmpdir } from "node:os";
21
11
  import { basename, dirname, join, parse, resolve } from "node:path";
22
12
 
23
- const FILE_MUTATION_LOCK_TIMEOUT_MS = 5000;
13
+ const FILE_MUTATION_LOCK_TIMEOUT_MS = process.platform === "win32" ? 15000 : 5000;
24
14
  const FILE_MUTATION_LOCK_STALE_MS = 30000;
25
15
  const FILE_MUTATION_LOCK_ROOT = join(tmpdir(), "pi-actors-file-locks");
26
16
 
@@ -56,6 +46,16 @@ export function mutationLockPath(path: string): string {
56
46
  return join(FILE_MUTATION_LOCK_ROOT, `${key}.lock`);
57
47
  }
58
48
 
49
+ function isZombieProcess(pid: number): boolean {
50
+ if (process.platform === "linux") try {
51
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
52
+ return stat.slice(stat.lastIndexOf(")") + 2).startsWith("Z ");
53
+ } catch { return false; }
54
+ if (process.platform !== "darwin") return false;
55
+ const result = spawnSync("ps", ["-p", String(pid), "-o", "stat="], { encoding: "utf8" });
56
+ return result.status === 0 && result.stdout.trim().startsWith("Z");
57
+ }
58
+
59
59
  function lockOwnerStatus(lockPath: string): "alive" | "dead" | "unknown" {
60
60
  try {
61
61
  const owner = JSON.parse(
@@ -65,6 +65,7 @@ function lockOwnerStatus(lockPath: string): "alive" | "dead" | "unknown" {
65
65
  if (!Number.isInteger(pid) || pid <= 0) return "unknown";
66
66
  try {
67
67
  process.kill(pid, 0);
68
+ if (Date.now() - statSync(lockPath).mtimeMs > 50 && isZombieProcess(pid)) return "dead";
68
69
  return "alive";
69
70
  } catch (error) {
70
71
  return (error as NodeJS.ErrnoException).code === "ESRCH"
@@ -77,6 +78,7 @@ function lockOwnerStatus(lockPath: string): "alive" | "dead" | "unknown" {
77
78
  }
78
79
 
79
80
  export interface FileMutationLockOptions {
81
+ onBeforeLockPublish?(): void;
80
82
  onBeforeReclaimRemove?(): void;
81
83
  onContention?(): void;
82
84
  onRemovalContention?(): void;
@@ -93,6 +95,28 @@ function readLockToken(lockPath: string): string | undefined {
93
95
  }
94
96
  }
95
97
 
98
+ function prepareLockBoundary(lockPath: string, token: string, onBeforePublish?: () => void): string {
99
+ const pendingPath = `${lockPath}.${process.pid}.${token}.pending`;
100
+ try {
101
+ mkdirSync(pendingPath);
102
+ writeFileSync(join(pendingPath, "owner.json"), `${JSON.stringify({ pid: process.pid, token, acquired_at: new Date().toISOString() })}\n`, "utf8");
103
+ onBeforePublish?.();
104
+ return pendingPath;
105
+ } catch (error) {
106
+ try { rmSync(pendingPath, { recursive: true, force: true }); } catch {}
107
+ throw error;
108
+ }
109
+ }
110
+
111
+ function removeLockBoundary(lockPath: string, token: string | undefined): boolean {
112
+ if (readLockToken(lockPath) !== token) return false;
113
+ if (process.platform === "win32") try { rmSync(lockPath, { recursive: true, force: true, maxRetries: 20, retryDelay: 5 }); return true; } catch { return false; }
114
+ const removingPath = `${lockPath}.${process.pid}.${randomUUID()}.removing`;
115
+ try { renameSync(lockPath, removingPath); } catch { return false; }
116
+ try { rmSync(removingPath, { recursive: true, force: true }); } catch {}
117
+ return true;
118
+ }
119
+
96
120
  function tryReclaimRemovalBoundary(reclaimPath: string): void {
97
121
  if (!existsSync(reclaimPath)) return;
98
122
  try {
@@ -104,7 +128,7 @@ function tryReclaimRemovalBoundary(reclaimPath: string): void {
104
128
  (ownerStatus === "unknown" && age > FILE_MUTATION_LOCK_STALE_MS)) &&
105
129
  readLockToken(reclaimPath) === inspectedToken
106
130
  ) {
107
- rmSync(reclaimPath, { recursive: true, force: true });
131
+ removeLockBoundary(reclaimPath, inspectedToken);
108
132
  }
109
133
  } catch {
110
134
  /* another contender changed the boundary */
@@ -117,29 +141,20 @@ function withRemovalBoundary(
117
141
  ): boolean {
118
142
  const reclaimPath = `${lockPath}.reclaim`;
119
143
  const token = randomUUID();
144
+ const pendingPath = prepareLockBoundary(reclaimPath, token);
120
145
  try {
121
- mkdirSync(reclaimPath);
146
+ try { renameSync(pendingPath, reclaimPath); } catch {
147
+ tryReclaimRemovalBoundary(reclaimPath);
148
+ return false;
149
+ }
122
150
  try {
123
- writeFileSync(
124
- join(reclaimPath, "owner.json"),
125
- `${JSON.stringify({ pid: process.pid, token, acquired_at: new Date().toISOString() })}\n`,
126
- "utf8",
127
- );
128
- } catch (error) {
129
- rmSync(reclaimPath, { recursive: true, force: true });
130
- throw error;
151
+ action();
152
+ return true;
153
+ } finally {
154
+ removeLockBoundary(reclaimPath, token);
131
155
  }
132
- } catch {
133
- tryReclaimRemovalBoundary(reclaimPath);
134
- return false;
135
- }
136
- try {
137
- action();
138
- return true;
139
156
  } finally {
140
- if (readLockToken(reclaimPath) === token) {
141
- rmSync(reclaimPath, { recursive: true, force: true });
142
- }
157
+ try { rmSync(pendingPath, { recursive: true, force: true }); } catch {}
143
158
  }
144
159
  }
145
160
 
@@ -162,8 +177,7 @@ function tryReclaimMutationLock(
162
177
  readLockToken(lockPath) === inspectedToken
163
178
  ) {
164
179
  options.onBeforeReclaimRemove?.();
165
- rmSync(lockPath, { recursive: true, force: true });
166
- reclaimed = true;
180
+ reclaimed = removeLockBoundary(lockPath, inspectedToken);
167
181
  }
168
182
  })) {
169
183
  return false;
@@ -179,34 +193,29 @@ export function acquireFileMutationLock(
179
193
  const lockPath = mutationLockPath(path);
180
194
  const deadline = Date.now() + FILE_MUTATION_LOCK_TIMEOUT_MS;
181
195
  const token = randomUUID();
196
+ const pendingPath = prepareLockBoundary(lockPath, token, options.onBeforeLockPublish);
182
197
  let contentionReported = false;
183
- for (;;) {
184
- try {
185
- mkdirSync(lockPath);
198
+ try {
199
+ for (;;) {
186
200
  try {
187
- writeFileSync(
188
- join(lockPath, "owner.json"),
189
- `${JSON.stringify({ pid: process.pid, token, acquired_at: new Date().toISOString() })}\n`,
190
- "utf8",
191
- );
201
+ renameSync(pendingPath, lockPath);
202
+ break;
192
203
  } catch (error) {
193
- rmSync(lockPath, { recursive: true, force: true });
194
- throw error;
195
- }
196
- break;
197
- } catch (error) {
198
- if (!contentionReported) {
199
- contentionReported = true;
200
- options.onContention?.();
201
- }
202
- tryReclaimMutationLock(lockPath, options);
203
- if (Date.now() >= deadline) {
204
- throw new Error(`Timed out waiting for file mutation lock: ${canonicalMutationPath(path)}`, {
205
- cause: error,
206
- });
204
+ if (!contentionReported) {
205
+ contentionReported = true;
206
+ options.onContention?.();
207
+ }
208
+ tryReclaimMutationLock(lockPath, options);
209
+ if (Date.now() >= deadline) {
210
+ throw new Error(`Timed out waiting for file mutation lock: ${canonicalMutationPath(path)}`, {
211
+ cause: error,
212
+ });
213
+ }
214
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
207
215
  }
208
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
209
216
  }
217
+ } finally {
218
+ try { rmSync(pendingPath, { recursive: true, force: true }); } catch {}
210
219
  }
211
220
  let released = false;
212
221
  return () => {
@@ -216,9 +225,7 @@ export function acquireFileMutationLock(
216
225
  let removalContentionReported = false;
217
226
  while (
218
227
  !withRemovalBoundary(lockPath, () => {
219
- if (readLockToken(lockPath) === token) {
220
- rmSync(lockPath, { recursive: true, force: true });
221
- }
228
+ removeLockBoundary(lockPath, token);
222
229
  })
223
230
  ) {
224
231
  if (!removalContentionReported) {