@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.
- package/AGENTS.md +12 -6
- package/BACKLOG.md +189 -1
- package/CHANGELOG.md +180 -301
- package/README.md +9 -7
- package/dist/index.js +1 -1
- package/dist/lib/async-runs.d.ts +2 -1
- package/dist/lib/async-runs.js +16 -4
- 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 +1 -0
- package/dist/lib/file-state.js +98 -42
- package/dist/lib/inspector-overlay.d.ts +2 -0
- package/dist/lib/inspector-overlay.js +92 -53
- package/dist/lib/limits.d.ts +5 -3
- package/dist/lib/limits.js +5 -3
- 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/runs-control-delivery.d.ts +8 -1
- package/dist/lib/runs-control-delivery.js +38 -15
- package/dist/lib/runs-controls.d.ts +2 -0
- package/dist/lib/runs-controls.js +5 -3
- package/dist/lib/runs-trace.d.ts +2 -2
- package/dist/lib/runs-trace.js +24 -20
- 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 +76 -0
- package/dist/lib/tool-review-scheduler.js +7 -7
- package/dist/lib/tools-inspect.js +53 -14
- 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 +30 -10
- package/dist/scripts/locker.mjs +9 -16
- package/dist/scripts/music-player.mjs +7 -13
- package/dist/scripts/release-gates.mjs +33 -2
- package/dist/scripts/validate-recipe.mjs +5 -4
- package/dist/skills/actors/SKILL.md +12 -7
- package/dist/skills/swarm/SKILL.md +0 -2
- package/docs/0.43-baseline.md +18 -23
- package/docs/README.md +2 -4
- package/docs/actor-inspector.md +5 -5
- package/docs/async-runs.md +2 -2
- package/docs/command-templates.md +6 -116
- package/docs/recipe-library.md +2 -4
- 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 +18 -5
- 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 +68 -61
- package/lib/inspector-overlay.ts +93 -58
- package/lib/limits.ts +5 -3
- package/lib/prompts.ts +1 -1
- package/lib/recipe-control.ts +9 -2
- package/lib/review-control.ts +4 -5
- package/lib/runs-control-delivery.ts +45 -17
- package/lib/runs-controls.ts +12 -3
- package/lib/runs-trace.ts +23 -19
- package/lib/runtime-identity.ts +39 -0
- package/lib/runtime-triage.ts +120 -0
- package/lib/tool-review-scheduler.ts +7 -7
- package/lib/tools-inspect.ts +60 -16
- 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 +33 -10
- package/package.json +1 -1
- package/scripts/locker.mjs +9 -16
- package/scripts/music-player.mjs +7 -13
- package/scripts/release-gates.mjs +33 -2
- package/scripts/validate-recipe.mjs +5 -4
- package/skills/actors/SKILL.md +12 -7
- package/skills/swarm/SKILL.md +0 -2
- 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/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,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
|
-
|
|
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
|
-
|
|
146
|
+
try { renameSync(pendingPath, reclaimPath); } catch {
|
|
147
|
+
tryReclaimRemovalBoundary(reclaimPath);
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
122
150
|
try {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
mkdirSync(lockPath);
|
|
198
|
+
try {
|
|
199
|
+
for (;;) {
|
|
186
200
|
try {
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
220
|
-
rmSync(lockPath, { recursive: true, force: true });
|
|
221
|
-
}
|
|
228
|
+
removeLockBoundary(lockPath, token);
|
|
222
229
|
})
|
|
223
230
|
) {
|
|
224
231
|
if (!removalContentionReported) {
|