@llblab/pi-actors 0.43.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/AGENTS.md +16 -10
  2. package/CHANGELOG.md +425 -536
  3. package/README.md +13 -11
  4. package/dist/index.js +1 -1
  5. package/dist/lib/async-runs.d.ts +2 -1
  6. package/dist/lib/async-runs.js +24 -34
  7. package/dist/lib/automatic-review-runtime.d.ts +1 -1
  8. package/dist/lib/automatic-review-runtime.js +5 -5
  9. package/dist/lib/command-templates.d.ts +2 -0
  10. package/dist/lib/command-templates.js +38 -4
  11. package/dist/lib/control-projection.d.ts +20 -0
  12. package/dist/lib/control-projection.js +66 -0
  13. package/dist/lib/control.d.ts +3 -0
  14. package/dist/lib/control.js +27 -14
  15. package/dist/lib/draft-sleep.js +3 -3
  16. package/dist/lib/file-state.d.ts +4 -1
  17. package/dist/lib/file-state.js +118 -44
  18. package/dist/lib/inspector-overlay.d.ts +2 -0
  19. package/dist/lib/inspector-overlay.js +124 -69
  20. package/dist/lib/limits.d.ts +15 -3
  21. package/dist/lib/limits.js +15 -3
  22. package/dist/lib/observability.d.ts +4 -2
  23. package/dist/lib/observability.js +43 -36
  24. package/dist/lib/prompts.d.ts +1 -1
  25. package/dist/lib/prompts.js +1 -1
  26. package/dist/lib/recipe-control.js +6 -2
  27. package/dist/lib/review-control.d.ts +1 -1
  28. package/dist/lib/review-control.js +4 -5
  29. package/dist/lib/run-evidence-policy.d.ts +95 -0
  30. package/dist/lib/run-evidence-policy.js +177 -0
  31. package/dist/lib/run-ui-runtime.js +2 -0
  32. package/dist/lib/runs-control-delivery.d.ts +8 -1
  33. package/dist/lib/runs-control-delivery.js +38 -15
  34. package/dist/lib/runs-controls.d.ts +8 -4
  35. package/dist/lib/runs-controls.js +189 -50
  36. package/dist/lib/runs-retention.js +27 -14
  37. package/dist/lib/runs-trace.d.ts +26 -2
  38. package/dist/lib/runs-trace.js +411 -18
  39. package/dist/lib/runtime-identity.d.ts +7 -0
  40. package/dist/lib/runtime-identity.js +35 -0
  41. package/dist/lib/runtime-triage.d.ts +29 -0
  42. package/dist/lib/runtime-triage.js +60 -0
  43. package/dist/lib/tool-review-scheduler.js +7 -7
  44. package/dist/lib/tools-inspect.js +91 -18
  45. package/dist/lib/tools-message.d.ts +1 -2
  46. package/dist/lib/tools-message.js +6 -6
  47. package/dist/lib/tools-response.d.ts +0 -1
  48. package/dist/lib/tools-response.js +0 -9
  49. package/dist/lib/tools.d.ts +1 -1
  50. package/dist/lib/tools.js +1 -1
  51. package/dist/lib/trace-projection.js +107 -41
  52. package/dist/scripts/conformance.mjs +5 -0
  53. package/dist/scripts/locker.mjs +40 -90
  54. package/dist/scripts/music-player.mjs +48 -142
  55. package/dist/scripts/release-gates.mjs +56 -3
  56. package/dist/scripts/validate-recipe.mjs +5 -4
  57. package/dist/skills/actors/SKILL.md +17 -11
  58. package/dist/skills/swarm/SKILL.md +2 -4
  59. package/docs/README.md +1 -4
  60. package/docs/actor-inspector.md +6 -5
  61. package/docs/async-runs.md +11 -9
  62. package/docs/command-templates.md +6 -116
  63. package/docs/recipe-library.md +4 -6
  64. package/docs/releasing.md +28 -0
  65. package/docs/template-recipes.md +1 -1
  66. package/docs/tool-registry.md +2 -2
  67. package/index.ts +1 -1
  68. package/lib/async-runs.ts +26 -50
  69. package/lib/automatic-review-runtime.ts +7 -7
  70. package/lib/command-templates.ts +44 -4
  71. package/lib/control-projection.ts +105 -0
  72. package/lib/control.ts +33 -18
  73. package/lib/draft-sleep.ts +3 -3
  74. package/lib/file-state.ts +91 -63
  75. package/lib/inspector-overlay.ts +108 -61
  76. package/lib/limits.ts +15 -3
  77. package/lib/observability.ts +55 -57
  78. package/lib/prompts.ts +1 -1
  79. package/lib/recipe-control.ts +9 -2
  80. package/lib/review-control.ts +4 -5
  81. package/lib/run-evidence-policy.ts +242 -0
  82. package/lib/run-ui-runtime.ts +2 -0
  83. package/lib/runs-control-delivery.ts +45 -17
  84. package/lib/runs-controls.ts +180 -102
  85. package/lib/runs-retention.ts +28 -20
  86. package/lib/runs-trace.ts +499 -20
  87. package/lib/runtime-identity.ts +39 -0
  88. package/lib/runtime-triage.ts +106 -0
  89. package/lib/tool-review-scheduler.ts +7 -7
  90. package/lib/tools-inspect.ts +94 -20
  91. package/lib/tools-message.ts +7 -8
  92. package/lib/tools-response.ts +0 -12
  93. package/lib/tools.ts +4 -4
  94. package/lib/trace-projection.ts +156 -71
  95. package/package.json +1 -1
  96. package/scripts/conformance.mjs +5 -0
  97. package/scripts/locker.mjs +40 -90
  98. package/scripts/music-player.mjs +48 -142
  99. package/scripts/release-gates.mjs +56 -3
  100. package/scripts/validate-recipe.mjs +5 -4
  101. package/skills/actors/SKILL.md +17 -11
  102. package/skills/swarm/SKILL.md +2 -4
  103. package/dist/lib/runtime-notifier.d.ts +0 -48
  104. package/dist/lib/runtime-notifier.js +0 -138
  105. package/docs/0.43-baseline.md +0 -44
  106. package/docs/actors-deep-reference.md +0 -108
  107. package/docs/component-recipes.md +0 -45
  108. package/docs/task-first-recipes.md +0 -261
  109. package/lib/runtime-notifier.ts +0 -211
@@ -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,24 +4,17 @@
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" ? 30000 : 15000;
24
14
  const FILE_MUTATION_LOCK_STALE_MS = 30000;
15
+ const FILE_MUTATION_LOCK_RECLAIM_POLL_MS = 100;
16
+ const FILE_MUTATION_LOCK_REMOVAL_GRACE_MS = 250;
17
+ const FILE_MUTATION_LOCK_MAX_WAIT_MS = 50;
25
18
  const FILE_MUTATION_LOCK_ROOT = join(tmpdir(), "pi-actors-file-locks");
26
19
 
27
20
  function canonicalMutationPath(path: string): string {
@@ -56,6 +49,16 @@ export function mutationLockPath(path: string): string {
56
49
  return join(FILE_MUTATION_LOCK_ROOT, `${key}.lock`);
57
50
  }
58
51
 
52
+ function isZombieProcess(pid: number): boolean {
53
+ if (process.platform === "linux") try {
54
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
55
+ return stat.slice(stat.lastIndexOf(")") + 2).startsWith("Z ");
56
+ } catch { return false; }
57
+ if (process.platform !== "darwin") return false;
58
+ const result = spawnSync("ps", ["-p", String(pid), "-o", "stat="], { encoding: "utf8" });
59
+ return result.status === 0 && result.stdout.trim().startsWith("Z");
60
+ }
61
+
59
62
  function lockOwnerStatus(lockPath: string): "alive" | "dead" | "unknown" {
60
63
  try {
61
64
  const owner = JSON.parse(
@@ -65,6 +68,8 @@ function lockOwnerStatus(lockPath: string): "alive" | "dead" | "unknown" {
65
68
  if (!Number.isInteger(pid) || pid <= 0) return "unknown";
66
69
  try {
67
70
  process.kill(pid, 0);
71
+ if (Date.now() - statSync(lockPath).mtimeMs > FILE_MUTATION_LOCK_REMOVAL_GRACE_MS &&
72
+ isZombieProcess(pid)) return "dead";
68
73
  return "alive";
69
74
  } catch (error) {
70
75
  return (error as NodeJS.ErrnoException).code === "ESRCH"
@@ -77,6 +82,7 @@ function lockOwnerStatus(lockPath: string): "alive" | "dead" | "unknown" {
77
82
  }
78
83
 
79
84
  export interface FileMutationLockOptions {
85
+ onBeforeLockPublish?(): void;
80
86
  onBeforeReclaimRemove?(): void;
81
87
  onContention?(): void;
82
88
  onRemovalContention?(): void;
@@ -93,18 +99,41 @@ function readLockToken(lockPath: string): string | undefined {
93
99
  }
94
100
  }
95
101
 
102
+ function prepareLockBoundary(lockPath: string, token: string, onBeforePublish?: () => void): string {
103
+ const pendingPath = `${lockPath}.${process.pid}.${token}.pending`;
104
+ try {
105
+ mkdirSync(pendingPath);
106
+ writeFileSync(join(pendingPath, "owner.json"), `${JSON.stringify({ pid: process.pid, token, acquired_at: new Date().toISOString() })}\n`, "utf8");
107
+ onBeforePublish?.();
108
+ return pendingPath;
109
+ } catch (error) {
110
+ try { rmSync(pendingPath, { recursive: true, force: true }); } catch {}
111
+ throw error;
112
+ }
113
+ }
114
+
115
+ function removeLockBoundary(lockPath: string, token: string | undefined): boolean {
116
+ if (readLockToken(lockPath) !== token) return false;
117
+ if (process.platform === "win32") try { rmSync(lockPath, { recursive: true, force: true, maxRetries: 20, retryDelay: 5 }); return true; } catch { return false; }
118
+ const removingPath = `${lockPath}.${process.pid}.${randomUUID()}.removing`;
119
+ try { renameSync(lockPath, removingPath); } catch { return false; }
120
+ try { rmSync(removingPath, { recursive: true, force: true }); } catch {}
121
+ return true;
122
+ }
123
+
96
124
  function tryReclaimRemovalBoundary(reclaimPath: string): void {
97
125
  if (!existsSync(reclaimPath)) return;
98
126
  try {
99
- const inspectedToken = readLockToken(reclaimPath);
100
127
  const age = Date.now() - statSync(reclaimPath).mtimeMs;
128
+ if (age <= FILE_MUTATION_LOCK_REMOVAL_GRACE_MS) return;
129
+ const inspectedToken = readLockToken(reclaimPath);
101
130
  const ownerStatus = lockOwnerStatus(reclaimPath);
102
131
  if (
103
132
  (ownerStatus === "dead" ||
104
133
  (ownerStatus === "unknown" && age > FILE_MUTATION_LOCK_STALE_MS)) &&
105
134
  readLockToken(reclaimPath) === inspectedToken
106
135
  ) {
107
- rmSync(reclaimPath, { recursive: true, force: true });
136
+ removeLockBoundary(reclaimPath, inspectedToken);
108
137
  }
109
138
  } catch {
110
139
  /* another contender changed the boundary */
@@ -117,29 +146,20 @@ function withRemovalBoundary(
117
146
  ): boolean {
118
147
  const reclaimPath = `${lockPath}.reclaim`;
119
148
  const token = randomUUID();
149
+ const pendingPath = prepareLockBoundary(reclaimPath, token);
120
150
  try {
121
- mkdirSync(reclaimPath);
151
+ try { renameSync(pendingPath, reclaimPath); } catch {
152
+ tryReclaimRemovalBoundary(reclaimPath);
153
+ return false;
154
+ }
122
155
  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;
156
+ action();
157
+ return true;
158
+ } finally {
159
+ removeLockBoundary(reclaimPath, token);
131
160
  }
132
- } catch {
133
- tryReclaimRemovalBoundary(reclaimPath);
134
- return false;
135
- }
136
- try {
137
- action();
138
- return true;
139
161
  } finally {
140
- if (readLockToken(reclaimPath) === token) {
141
- rmSync(reclaimPath, { recursive: true, force: true });
142
- }
162
+ try { rmSync(pendingPath, { recursive: true, force: true }); } catch {}
143
163
  }
144
164
  }
145
165
 
@@ -162,8 +182,7 @@ function tryReclaimMutationLock(
162
182
  readLockToken(lockPath) === inspectedToken
163
183
  ) {
164
184
  options.onBeforeReclaimRemove?.();
165
- rmSync(lockPath, { recursive: true, force: true });
166
- reclaimed = true;
185
+ reclaimed = removeLockBoundary(lockPath, inspectedToken);
167
186
  }
168
187
  })) {
169
188
  return false;
@@ -179,34 +198,40 @@ export function acquireFileMutationLock(
179
198
  const lockPath = mutationLockPath(path);
180
199
  const deadline = Date.now() + FILE_MUTATION_LOCK_TIMEOUT_MS;
181
200
  const token = randomUUID();
201
+ const pendingPath = prepareLockBoundary(lockPath, token, options.onBeforeLockPublish);
182
202
  let contentionReported = false;
183
- for (;;) {
184
- try {
185
- mkdirSync(lockPath);
203
+ let nextReclaimAt = 0;
204
+ let waitMs = 10;
205
+ try {
206
+ for (;;) {
186
207
  try {
187
- writeFileSync(
188
- join(lockPath, "owner.json"),
189
- `${JSON.stringify({ pid: process.pid, token, acquired_at: new Date().toISOString() })}\n`,
190
- "utf8",
191
- );
208
+ renameSync(pendingPath, lockPath);
209
+ break;
192
210
  } 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
- });
211
+ if (!contentionReported) {
212
+ contentionReported = true;
213
+ options.onContention?.();
214
+ }
215
+ const now = Date.now();
216
+ if (now >= nextReclaimAt) {
217
+ nextReclaimAt = now + FILE_MUTATION_LOCK_RECLAIM_POLL_MS;
218
+ if (tryReclaimMutationLock(lockPath, options)) {
219
+ waitMs = 10;
220
+ continue;
221
+ }
222
+ }
223
+ if (now >= deadline) {
224
+ throw new Error(`Timed out waiting for file mutation lock: ${canonicalMutationPath(path)}`, {
225
+ cause: error,
226
+ });
227
+ }
228
+ const jitter = Math.floor(Math.random() * Math.max(1, Math.floor(waitMs / 2)));
229
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, waitMs + jitter);
230
+ waitMs = Math.min(FILE_MUTATION_LOCK_MAX_WAIT_MS, waitMs + 5);
207
231
  }
208
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
209
232
  }
233
+ } finally {
234
+ try { rmSync(pendingPath, { recursive: true, force: true }); } catch {}
210
235
  }
211
236
  let released = false;
212
237
  return () => {
@@ -216,9 +241,7 @@ export function acquireFileMutationLock(
216
241
  let removalContentionReported = false;
217
242
  while (
218
243
  !withRemovalBoundary(lockPath, () => {
219
- if (readLockToken(lockPath) === token) {
220
- rmSync(lockPath, { recursive: true, force: true });
221
- }
244
+ removeLockBoundary(lockPath, token);
222
245
  })
223
246
  ) {
224
247
  if (!removalContentionReported) {
@@ -244,11 +267,16 @@ export function withFileMutationLock<T>(
244
267
  }
245
268
  }
246
269
 
247
- export function writeTextAtomic(path: string, content: string): void {
270
+ export function writeTextAtomic(
271
+ path: string,
272
+ content: string,
273
+ options: { onBeforeReplace?(): void } = {},
274
+ ): void {
248
275
  mkdirSync(dirname(path), { recursive: true });
249
276
  const tempPath = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
250
277
  try {
251
278
  writeFileSync(tempPath, content, "utf8");
279
+ options.onBeforeReplace?.();
252
280
  renameSync(tempPath, path);
253
281
  } catch (error) {
254
282
  try {