@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.
- package/AGENTS.md +16 -10
- package/CHANGELOG.md +425 -536
- package/README.md +13 -11
- package/dist/index.js +1 -1
- package/dist/lib/async-runs.d.ts +2 -1
- package/dist/lib/async-runs.js +24 -34
- package/dist/lib/automatic-review-runtime.d.ts +1 -1
- package/dist/lib/automatic-review-runtime.js +5 -5
- package/dist/lib/command-templates.d.ts +2 -0
- package/dist/lib/command-templates.js +38 -4
- package/dist/lib/control-projection.d.ts +20 -0
- package/dist/lib/control-projection.js +66 -0
- package/dist/lib/control.d.ts +3 -0
- package/dist/lib/control.js +27 -14
- package/dist/lib/draft-sleep.js +3 -3
- package/dist/lib/file-state.d.ts +4 -1
- package/dist/lib/file-state.js +118 -44
- package/dist/lib/inspector-overlay.d.ts +2 -0
- package/dist/lib/inspector-overlay.js +124 -69
- package/dist/lib/limits.d.ts +15 -3
- package/dist/lib/limits.js +15 -3
- package/dist/lib/observability.d.ts +4 -2
- package/dist/lib/observability.js +43 -36
- package/dist/lib/prompts.d.ts +1 -1
- package/dist/lib/prompts.js +1 -1
- package/dist/lib/recipe-control.js +6 -2
- package/dist/lib/review-control.d.ts +1 -1
- package/dist/lib/review-control.js +4 -5
- package/dist/lib/run-evidence-policy.d.ts +95 -0
- package/dist/lib/run-evidence-policy.js +177 -0
- package/dist/lib/run-ui-runtime.js +2 -0
- package/dist/lib/runs-control-delivery.d.ts +8 -1
- package/dist/lib/runs-control-delivery.js +38 -15
- package/dist/lib/runs-controls.d.ts +8 -4
- package/dist/lib/runs-controls.js +189 -50
- package/dist/lib/runs-retention.js +27 -14
- package/dist/lib/runs-trace.d.ts +26 -2
- package/dist/lib/runs-trace.js +411 -18
- package/dist/lib/runtime-identity.d.ts +7 -0
- package/dist/lib/runtime-identity.js +35 -0
- package/dist/lib/runtime-triage.d.ts +29 -0
- package/dist/lib/runtime-triage.js +60 -0
- package/dist/lib/tool-review-scheduler.js +7 -7
- package/dist/lib/tools-inspect.js +91 -18
- package/dist/lib/tools-message.d.ts +1 -2
- package/dist/lib/tools-message.js +6 -6
- package/dist/lib/tools-response.d.ts +0 -1
- package/dist/lib/tools-response.js +0 -9
- package/dist/lib/tools.d.ts +1 -1
- package/dist/lib/tools.js +1 -1
- package/dist/lib/trace-projection.js +107 -41
- package/dist/scripts/conformance.mjs +5 -0
- package/dist/scripts/locker.mjs +40 -90
- package/dist/scripts/music-player.mjs +48 -142
- package/dist/scripts/release-gates.mjs +56 -3
- package/dist/scripts/validate-recipe.mjs +5 -4
- package/dist/skills/actors/SKILL.md +17 -11
- package/dist/skills/swarm/SKILL.md +2 -4
- package/docs/README.md +1 -4
- package/docs/actor-inspector.md +6 -5
- package/docs/async-runs.md +11 -9
- package/docs/command-templates.md +6 -116
- package/docs/recipe-library.md +4 -6
- package/docs/releasing.md +28 -0
- package/docs/template-recipes.md +1 -1
- package/docs/tool-registry.md +2 -2
- package/index.ts +1 -1
- package/lib/async-runs.ts +26 -50
- package/lib/automatic-review-runtime.ts +7 -7
- package/lib/command-templates.ts +44 -4
- package/lib/control-projection.ts +105 -0
- package/lib/control.ts +33 -18
- package/lib/draft-sleep.ts +3 -3
- package/lib/file-state.ts +91 -63
- package/lib/inspector-overlay.ts +108 -61
- package/lib/limits.ts +15 -3
- package/lib/observability.ts +55 -57
- package/lib/prompts.ts +1 -1
- package/lib/recipe-control.ts +9 -2
- package/lib/review-control.ts +4 -5
- package/lib/run-evidence-policy.ts +242 -0
- package/lib/run-ui-runtime.ts +2 -0
- package/lib/runs-control-delivery.ts +45 -17
- package/lib/runs-controls.ts +180 -102
- package/lib/runs-retention.ts +28 -20
- package/lib/runs-trace.ts +499 -20
- package/lib/runtime-identity.ts +39 -0
- package/lib/runtime-triage.ts +106 -0
- package/lib/tool-review-scheduler.ts +7 -7
- package/lib/tools-inspect.ts +94 -20
- package/lib/tools-message.ts +7 -8
- package/lib/tools-response.ts +0 -12
- package/lib/tools.ts +4 -4
- package/lib/trace-projection.ts +156 -71
- package/package.json +1 -1
- package/scripts/conformance.mjs +5 -0
- package/scripts/locker.mjs +40 -90
- package/scripts/music-player.mjs +48 -142
- package/scripts/release-gates.mjs +56 -3
- package/scripts/validate-recipe.mjs +5 -4
- package/skills/actors/SKILL.md +17 -11
- package/skills/swarm/SKILL.md +2 -4
- package/dist/lib/runtime-notifier.d.ts +0 -48
- package/dist/lib/runtime-notifier.js +0 -138
- package/docs/0.43-baseline.md +0 -44
- package/docs/actors-deep-reference.md +0 -108
- package/docs/component-recipes.md +0 -45
- package/docs/task-first-recipes.md +0 -261
- package/lib/runtime-notifier.ts +0 -211
package/lib/command-templates.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
...(
|
|
114
|
+
...(normalizedInput !== undefined ? { input: normalizedInput } : {}),
|
|
100
115
|
...(record.verbose !== undefined ? { verbose: record.verbose } : {}),
|
|
101
116
|
};
|
|
102
117
|
}
|
package/lib/draft-sleep.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
151
|
+
try { renameSync(pendingPath, reclaimPath); } catch {
|
|
152
|
+
tryReclaimRemovalBoundary(reclaimPath);
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
122
155
|
try {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
203
|
+
let nextReclaimAt = 0;
|
|
204
|
+
let waitMs = 10;
|
|
205
|
+
try {
|
|
206
|
+
for (;;) {
|
|
186
207
|
try {
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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(
|
|
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 {
|