@llblab/pi-actors 0.42.3 → 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 +127 -175
- package/BACKLOG.md +189 -1
- package/CHANGELOG.md +183 -292
- package/README.md +115 -276
- package/dist/fixtures/protocol/control-endpoint.json +6 -0
- package/dist/fixtures/protocol/control-record.json +9 -0
- package/dist/fixtures/protocol/recipe-summary.json +4 -12
- package/dist/fixtures/protocol/trace-event.json +9 -0
- package/dist/index.js +1 -1
- package/dist/lib/async-runs.d.ts +15 -38
- package/dist/lib/async-runs.js +173 -111
- 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 +15 -0
- package/dist/lib/control.js +97 -0
- package/dist/lib/draft-sleep.js +3 -3
- package/dist/lib/execution-sessions.d.ts +17 -0
- package/dist/lib/execution-sessions.js +85 -0
- package/dist/lib/file-state.d.ts +2 -0
- package/dist/lib/file-state.js +114 -46
- package/dist/lib/inspector-actions.d.ts +2 -2
- package/dist/lib/inspector-actions.js +2 -2
- package/dist/lib/inspector-command.js +3 -3
- package/dist/lib/inspector-overlay.d.ts +54 -70
- package/dist/lib/inspector-overlay.js +576 -910
- package/dist/lib/inspector.d.ts +3 -71
- package/dist/lib/inspector.js +19 -665
- package/dist/lib/limits.d.ts +7 -3
- package/dist/lib/limits.js +7 -3
- package/dist/lib/observability.d.ts +16 -16
- package/dist/lib/observability.js +43 -82
- package/dist/lib/prompts.d.ts +1 -1
- package/dist/lib/prompts.js +3 -3
- package/dist/lib/recipe-control.d.ts +7 -0
- package/dist/lib/recipe-control.js +43 -0
- package/dist/lib/recipes-discovery.js +2 -0
- package/dist/lib/recipes-references.d.ts +1 -14
- package/dist/lib/recipes-references.js +6 -21
- package/dist/lib/review-control.d.ts +1 -1
- package/dist/lib/review-control.js +4 -5
- package/dist/lib/review-projection.js +1 -5
- package/dist/lib/run-ui-runtime.js +2 -2
- package/dist/lib/runs-control-delivery.d.ts +28 -0
- package/dist/lib/runs-control-delivery.js +150 -0
- package/dist/lib/runs-controls.d.ts +37 -0
- package/dist/lib/runs-controls.js +146 -0
- package/dist/lib/runs-retention.d.ts +7 -0
- package/dist/lib/runs-retention.js +27 -3
- package/dist/lib/runs-start.js +4 -2
- package/dist/lib/runs-status.js +11 -6
- package/dist/lib/runs-trace.d.ts +24 -0
- package/dist/lib/runs-trace.js +102 -0
- package/dist/lib/runtime-identity.d.ts +7 -0
- package/dist/lib/runtime-identity.js +35 -0
- package/dist/lib/runtime-notifier.d.ts +1 -1
- package/dist/lib/runtime-notifier.js +1 -1
- 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.d.ts +3 -3
- package/dist/lib/tools-inspect.js +241 -707
- package/dist/lib/tools-local.js +2 -10
- package/dist/lib/tools-message.d.ts +6 -7
- package/dist/lib/tools-message.js +95 -396
- package/dist/lib/tools-response.d.ts +1 -5
- package/dist/lib/tools-response.js +5 -48
- package/dist/lib/tools-spawn.js +16 -28
- package/dist/lib/tools.d.ts +1 -1
- package/dist/lib/tools.js +2 -3
- package/dist/lib/trace-projection.d.ts +22 -0
- package/dist/lib/trace-projection.js +185 -0
- package/dist/recipes/draft-review.json +0 -10
- package/dist/recipes/lens-swarm.json +0 -14
- package/dist/recipes/music-player.json +10 -19
- package/dist/recipes/pipeline-architect-coordinator.json +0 -11
- package/dist/recipes/pipeline-artifact-bundle.json +1 -22
- package/dist/recipes/pipeline-artifact-report.json +1 -18
- package/dist/recipes/pipeline-artifact-write.json +1 -18
- package/dist/recipes/pipeline-async-run-ops.json +0 -12
- package/dist/recipes/pipeline-checkpoint-continuation.json +0 -14
- package/dist/recipes/pipeline-development-tasking.json +0 -12
- package/dist/recipes/pipeline-docs-maintenance.json +0 -12
- package/dist/recipes/pipeline-media-library.json +0 -12
- package/dist/recipes/pipeline-quorum-review.json +0 -12
- package/dist/recipes/pipeline-release-readiness.json +0 -12
- package/dist/recipes/pipeline-release-summary.json +0 -12
- package/dist/recipes/pipeline-repo-health.json +0 -12
- package/dist/recipes/pipeline-research-synthesis.json +0 -11
- package/dist/recipes/pipeline-review-readiness.json +0 -12
- package/dist/recipes/resource-locker.json +27 -0
- package/dist/recipes/subagent-artifact.json +0 -9
- package/dist/recipes/subagent-checkpoint.json +0 -10
- package/dist/recipes/subagent-conflict-report.json +0 -11
- package/dist/recipes/subagent-contradiction-map.json +0 -11
- package/dist/recipes/subagent-critic.json +0 -11
- package/dist/recipes/subagent-evidence-map.json +0 -11
- package/dist/recipes/subagent-followup.json +0 -10
- package/dist/recipes/subagent-judge.json +0 -11
- package/dist/recipes/subagent-merge.json +0 -11
- package/dist/recipes/subagent-normalize.json +0 -11
- package/dist/recipes/subagent-plan.json +0 -11
- package/dist/recipes/subagent-preflight.json +0 -11
- package/dist/recipes/subagent-prompt.json +0 -10
- package/dist/recipes/subagent-quorum.json +0 -10
- package/dist/recipes/subagent-review-coordinator.json +0 -14
- package/dist/recipes/subagent-review.json +0 -11
- package/dist/recipes/subagent-task-card.json +0 -11
- package/dist/recipes/subagent-tools.json +0 -10
- package/dist/recipes/subagent-verify.json +0 -11
- package/dist/recipes/subagents-prompts.json +0 -10
- package/dist/recipes/tool-review.json +0 -10
- package/dist/scripts/async-runner.mjs +25 -25
- package/dist/scripts/conformance.mjs +4 -2
- package/dist/scripts/locker.mjs +196 -69
- package/dist/scripts/music-player.mjs +162 -159
- package/dist/scripts/recipe-utils.mjs +6 -96
- package/dist/scripts/release-gates.mjs +91 -0
- package/dist/scripts/validate-recipe.mjs +8 -57
- package/dist/skills/actors/SKILL.md +57 -265
- package/dist/skills/swarm/SKILL.md +10 -34
- package/docs/0.43-baseline.md +39 -0
- package/docs/README.md +4 -6
- package/docs/actor-inspector.md +26 -64
- package/docs/async-runs.md +81 -328
- package/docs/command-templates.md +8 -118
- package/docs/recipe-library.md +55 -182
- package/docs/releasing.md +28 -0
- package/docs/template-recipes.md +76 -289
- package/docs/tool-registry.md +41 -161
- package/fixtures/protocol/control-endpoint.json +6 -0
- package/fixtures/protocol/control-record.json +9 -0
- package/fixtures/protocol/recipe-summary.json +4 -12
- package/fixtures/protocol/trace-event.json +9 -0
- package/index.ts +1 -1
- package/lib/async-runs.ts +218 -204
- 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 +117 -0
- package/lib/draft-sleep.ts +3 -3
- package/lib/execution-sessions.ts +111 -0
- package/lib/file-state.ts +84 -64
- package/lib/inspector-actions.ts +2 -2
- package/lib/inspector-command.ts +3 -3
- package/lib/inspector-overlay.ts +617 -1126
- package/lib/inspector.ts +46 -979
- package/lib/limits.ts +7 -3
- package/lib/observability.ts +60 -101
- package/lib/prompts.ts +3 -3
- package/lib/recipe-control.ts +52 -0
- package/lib/recipes-discovery.ts +2 -0
- package/lib/recipes-references.ts +9 -45
- package/lib/review-control.ts +4 -5
- package/lib/review-projection.ts +1 -5
- package/lib/run-ui-runtime.ts +2 -2
- package/lib/runs-control-delivery.ts +209 -0
- package/lib/runs-controls.ts +213 -0
- package/lib/runs-retention.ts +38 -3
- package/lib/runs-start.ts +4 -2
- package/lib/runs-status.ts +11 -6
- package/lib/runs-trace.ts +136 -0
- package/lib/runtime-identity.ts +39 -0
- package/lib/runtime-notifier.ts +1 -1
- package/lib/runtime-triage.ts +120 -0
- package/lib/tool-review-scheduler.ts +7 -7
- package/lib/tools-inspect.ts +283 -900
- package/lib/tools-local.ts +2 -12
- package/lib/tools-message.ts +112 -520
- package/lib/tools-response.ts +5 -64
- package/lib/tools-spawn.ts +16 -32
- package/lib/tools.ts +5 -6
- package/lib/trace-projection.ts +244 -0
- package/package.json +2 -1
- package/recipes/draft-review.json +0 -10
- package/recipes/lens-swarm.json +0 -14
- package/recipes/music-player.json +10 -19
- package/recipes/pipeline-architect-coordinator.json +0 -11
- package/recipes/pipeline-artifact-bundle.json +1 -22
- package/recipes/pipeline-artifact-report.json +1 -18
- package/recipes/pipeline-artifact-write.json +1 -18
- package/recipes/pipeline-async-run-ops.json +0 -12
- package/recipes/pipeline-checkpoint-continuation.json +0 -14
- package/recipes/pipeline-development-tasking.json +0 -12
- package/recipes/pipeline-docs-maintenance.json +0 -12
- package/recipes/pipeline-media-library.json +0 -12
- package/recipes/pipeline-quorum-review.json +0 -12
- package/recipes/pipeline-release-readiness.json +0 -12
- package/recipes/pipeline-release-summary.json +0 -12
- package/recipes/pipeline-repo-health.json +0 -12
- package/recipes/pipeline-research-synthesis.json +0 -11
- package/recipes/pipeline-review-readiness.json +0 -12
- package/recipes/resource-locker.json +27 -0
- package/recipes/subagent-artifact.json +0 -9
- package/recipes/subagent-checkpoint.json +0 -10
- package/recipes/subagent-conflict-report.json +0 -11
- package/recipes/subagent-contradiction-map.json +0 -11
- package/recipes/subagent-critic.json +0 -11
- package/recipes/subagent-evidence-map.json +0 -11
- package/recipes/subagent-followup.json +0 -10
- package/recipes/subagent-judge.json +0 -11
- package/recipes/subagent-merge.json +0 -11
- package/recipes/subagent-normalize.json +0 -11
- package/recipes/subagent-plan.json +0 -11
- package/recipes/subagent-preflight.json +0 -11
- package/recipes/subagent-prompt.json +0 -10
- package/recipes/subagent-quorum.json +0 -10
- package/recipes/subagent-review-coordinator.json +0 -14
- package/recipes/subagent-review.json +0 -11
- package/recipes/subagent-task-card.json +0 -11
- package/recipes/subagent-tools.json +0 -10
- package/recipes/subagent-verify.json +0 -11
- package/recipes/subagents-prompts.json +0 -10
- package/recipes/tool-review.json +0 -10
- package/scripts/async-runner.mjs +25 -25
- package/scripts/conformance.mjs +4 -2
- package/scripts/locker.mjs +196 -69
- package/scripts/music-player.mjs +162 -159
- package/scripts/recipe-utils.mjs +6 -96
- package/scripts/release-gates.mjs +91 -0
- package/scripts/validate-recipe.mjs +8 -57
- package/skills/actors/SKILL.md +57 -265
- package/skills/swarm/SKILL.md +10 -34
- package/dist/fixtures/protocol/actor-message-branch.json +0 -13
- package/dist/fixtures/protocol/mailbox-contract.json +0 -15
- package/dist/fixtures/protocol/room-message.json +0 -11
- package/dist/fixtures/protocol/room-roster.json +0 -11
- package/dist/fixtures/protocol/run-inbox-message.json +0 -9
- package/dist/fixtures/protocol/run-outbox-event.json +0 -9
- package/dist/lib/mailbox-loop.d.ts +0 -41
- package/dist/lib/mailbox-loop.js +0 -60
- package/dist/lib/messages.d.ts +0 -25
- package/dist/lib/messages.js +0 -122
- package/dist/lib/rooms.d.ts +0 -104
- package/dist/lib/rooms.js +0 -647
- package/dist/lib/runs-mailbox.d.ts +0 -25
- package/dist/lib/runs-mailbox.js +0 -146
- package/dist/lib/runs-messages.d.ts +0 -15
- package/dist/lib/runs-messages.js +0 -179
- package/dist/lib/runs-outbox.d.ts +0 -41
- package/dist/lib/runs-outbox.js +0 -87
- package/dist/lib/tools-mailbox.d.ts +0 -8
- package/dist/lib/tools-mailbox.js +0 -48
- package/dist/recipes/actor-worker.json +0 -39
- package/dist/recipes/coordinator-locker.json +0 -45
- package/dist/recipes/locker.json +0 -45
- package/dist/recipes/pipeline-room-swarm.json +0 -50
- package/dist/recipes/subagent-message.json +0 -32
- package/dist/recipes/utility-actor-message.json +0 -23
- package/dist/scripts/actor-worker.mjs +0 -214
- package/dist/scripts/coordinator.mjs +0 -799
- package/docs/actor-messages.md +0 -225
- package/docs/actors-deep-reference.md +0 -66
- package/docs/component-recipes.md +0 -148
- package/docs/task-first-recipes.md +0 -263
- package/fixtures/protocol/actor-message-branch.json +0 -13
- package/fixtures/protocol/mailbox-contract.json +0 -15
- package/fixtures/protocol/room-message.json +0 -11
- package/fixtures/protocol/room-roster.json +0 -11
- package/fixtures/protocol/run-inbox-message.json +0 -9
- package/fixtures/protocol/run-outbox-event.json +0 -9
- package/lib/mailbox-loop.ts +0 -144
- package/lib/messages.ts +0 -151
- package/lib/rooms.ts +0 -939
- package/lib/runs-mailbox.ts +0 -208
- package/lib/runs-messages.ts +0 -252
- package/lib/runs-outbox.ts +0 -144
- package/lib/tools-mailbox.ts +0 -56
- package/recipes/actor-worker.json +0 -39
- package/recipes/coordinator-locker.json +0 -45
- package/recipes/locker.json +0 -45
- package/recipes/pipeline-room-swarm.json +0 -50
- package/recipes/subagent-message.json +0 -32
- package/recipes/utility-actor-message.json +0 -23
- package/scripts/actor-worker.mjs +0 -214
- package/scripts/coordinator.mjs +0 -799
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
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run Control request contract.
|
|
3
|
+
* Zones: public request validation, Run/runtime target normalization, input bounds
|
|
4
|
+
* Owns pure Control validation; journaling, delivery, and lifecycle mutation stay in adapters.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as Limits from "./limits.ts";
|
|
8
|
+
|
|
9
|
+
export interface ControlRequest {
|
|
10
|
+
target: `run:${string}` | "runtime";
|
|
11
|
+
action: string;
|
|
12
|
+
input?: unknown;
|
|
13
|
+
verbose?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const RUN_ID_PATTERN = /^[A-Za-z0-9_.-]+$/;
|
|
17
|
+
const ACTION_PATTERN = /^[a-z][a-z0-9_-]*(?:\.[a-z0-9_-]+)*$/;
|
|
18
|
+
const CONTROL_FIELDS = new Set(["action", "input", "target", "verbose"]);
|
|
19
|
+
const REMOVED_FIELDS = new Set([
|
|
20
|
+
"body",
|
|
21
|
+
"correlation_id",
|
|
22
|
+
"from",
|
|
23
|
+
"metadata",
|
|
24
|
+
"reply_to",
|
|
25
|
+
"summary",
|
|
26
|
+
"to",
|
|
27
|
+
"type",
|
|
28
|
+
]);
|
|
29
|
+
|
|
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 {
|
|
51
|
+
let serialized: string | undefined;
|
|
52
|
+
try {
|
|
53
|
+
serialized = JSON.stringify(input);
|
|
54
|
+
} catch {
|
|
55
|
+
throw new Error("control.input must be JSON-serializable");
|
|
56
|
+
}
|
|
57
|
+
if (serialized === undefined) {
|
|
58
|
+
throw new Error("control.input must be JSON-serializable");
|
|
59
|
+
}
|
|
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;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function normalizeTarget(value: unknown): ControlRequest["target"] {
|
|
70
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
71
|
+
throw new Error("control.target is required");
|
|
72
|
+
}
|
|
73
|
+
const target = value.trim();
|
|
74
|
+
if (target === "runtime") return target;
|
|
75
|
+
if (!target.startsWith("run:")) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`unsupported control target: ${target}; use run:<id> or runtime`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
const run = target.slice(4);
|
|
81
|
+
if (!RUN_ID_PATTERN.test(run)) {
|
|
82
|
+
throw new Error(`invalid control Run target: ${target}`);
|
|
83
|
+
}
|
|
84
|
+
return `run:${run}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function normalizeControlRequest(input: unknown): ControlRequest {
|
|
88
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
89
|
+
throw new Error("control request must be an object");
|
|
90
|
+
}
|
|
91
|
+
const record = input as Record<string, unknown>;
|
|
92
|
+
const fields = Object.keys(record);
|
|
93
|
+
const removed = fields.filter((field) => REMOVED_FIELDS.has(field));
|
|
94
|
+
if (removed.length > 0) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`actor-message fields are removed: ${removed.sort().join(", ")}; use target, action, input, verbose`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
const unknown = fields.filter((field) => !CONTROL_FIELDS.has(field));
|
|
100
|
+
if (unknown.length > 0) {
|
|
101
|
+
throw new Error(`unsupported control fields: ${unknown.sort().join(", ")}`);
|
|
102
|
+
}
|
|
103
|
+
const target = normalizeTarget(record.target);
|
|
104
|
+
const action = normalizeControlAction(record.action);
|
|
105
|
+
if (record.verbose !== undefined && typeof record.verbose !== "boolean") {
|
|
106
|
+
throw new Error("control.verbose must be a boolean");
|
|
107
|
+
}
|
|
108
|
+
const normalizedInput = record.input === undefined
|
|
109
|
+
? undefined
|
|
110
|
+
: normalizeControlInput(record.input);
|
|
111
|
+
return {
|
|
112
|
+
target,
|
|
113
|
+
action,
|
|
114
|
+
...(normalizedInput !== undefined ? { input: normalizedInput } : {}),
|
|
115
|
+
...(record.verbose !== undefined ? { verbose: record.verbose } : {}),
|
|
116
|
+
};
|
|
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",
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run-owned execution session evidence.
|
|
3
|
+
* Zones: execution manifest session discovery, canonical containment, rich turn correlation
|
|
4
|
+
* Owns reusable agent-turn loading; Trace and TUI projection stay in their adapters.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
|
|
10
|
+
import * as SessionEvidence from "./session-evidence.ts";
|
|
11
|
+
import { readJsonFileResilient } from "./state-readers.ts";
|
|
12
|
+
|
|
13
|
+
export interface ExecutionTurnItem extends SessionEvidence.SessionEvidenceTurn {
|
|
14
|
+
commandId: string;
|
|
15
|
+
diagnostics: string[];
|
|
16
|
+
promptBytes?: number;
|
|
17
|
+
promptFile?: string;
|
|
18
|
+
recipeContext?: unknown;
|
|
19
|
+
sessionFile: string;
|
|
20
|
+
sessionTruncated: boolean;
|
|
21
|
+
stage?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function ownedSessionPath(
|
|
25
|
+
stateDir: string,
|
|
26
|
+
sessionFile: string,
|
|
27
|
+
): string | undefined {
|
|
28
|
+
if (path.isAbsolute(sessionFile)) return undefined;
|
|
29
|
+
const sessionsRoot = path.resolve(stateDir, "sessions");
|
|
30
|
+
const resolved = path.resolve(stateDir, sessionFile);
|
|
31
|
+
if (!resolved.startsWith(`${sessionsRoot}${path.sep}`)) return undefined;
|
|
32
|
+
try {
|
|
33
|
+
const canonicalRoot = fs.realpathSync(sessionsRoot);
|
|
34
|
+
const canonicalFile = fs.realpathSync(resolved);
|
|
35
|
+
return canonicalFile.startsWith(`${canonicalRoot}${path.sep}`)
|
|
36
|
+
? canonicalFile
|
|
37
|
+
: undefined;
|
|
38
|
+
} catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function readExecutionTurns(stateDir: string): ExecutionTurnItem[] {
|
|
44
|
+
const execution = readJsonFileResilient<Record<string, unknown>>(
|
|
45
|
+
path.join(stateDir, "execution.json"),
|
|
46
|
+
{},
|
|
47
|
+
).value;
|
|
48
|
+
const commands = Array.isArray(execution.commands)
|
|
49
|
+
? (execution.commands as Array<Record<string, unknown>>)
|
|
50
|
+
: [];
|
|
51
|
+
const recordedFiles = new Set(
|
|
52
|
+
commands.flatMap((command) =>
|
|
53
|
+
(Array.isArray(command.session_files) ? command.session_files : []).filter(
|
|
54
|
+
(file): file is string => typeof file === "string",
|
|
55
|
+
),
|
|
56
|
+
),
|
|
57
|
+
);
|
|
58
|
+
let discoveredCommands: Array<Record<string, unknown>> = [];
|
|
59
|
+
try {
|
|
60
|
+
const sessionsDir = path.join(stateDir, "sessions");
|
|
61
|
+
discoveredCommands = fs
|
|
62
|
+
.readdirSync(sessionsDir, { withFileTypes: true })
|
|
63
|
+
.filter((entry) => entry.isDirectory())
|
|
64
|
+
.flatMap((entry) => {
|
|
65
|
+
const files = fs
|
|
66
|
+
.readdirSync(path.join(sessionsDir, entry.name), {
|
|
67
|
+
withFileTypes: true,
|
|
68
|
+
})
|
|
69
|
+
.filter((file) => file.isFile() && file.name.endsWith(".jsonl"))
|
|
70
|
+
.map((file) => path.posix.join("sessions", entry.name, file.name))
|
|
71
|
+
.filter((file) => !recordedFiles.has(file));
|
|
72
|
+
return files.length > 0
|
|
73
|
+
? [{ id: entry.name, session_files: files, stage: "subagent" }]
|
|
74
|
+
: [];
|
|
75
|
+
});
|
|
76
|
+
} catch {
|
|
77
|
+
discoveredCommands = [];
|
|
78
|
+
}
|
|
79
|
+
return [...commands, ...discoveredCommands]
|
|
80
|
+
.flatMap((command) =>
|
|
81
|
+
(Array.isArray(command.session_files) ? command.session_files : [])
|
|
82
|
+
.filter((file): file is string => typeof file === "string")
|
|
83
|
+
.flatMap((file) => {
|
|
84
|
+
const sessionPath = ownedSessionPath(stateDir, file);
|
|
85
|
+
if (!sessionPath) return [];
|
|
86
|
+
const session = SessionEvidence.readSessionEvidence(sessionPath);
|
|
87
|
+
return session.turns.map((turn) => ({
|
|
88
|
+
...turn,
|
|
89
|
+
commandId: String(command.id ?? "unknown"),
|
|
90
|
+
diagnostics: session.diagnostics.map((item) =>
|
|
91
|
+
`${item.line === undefined ? "" : `line ${item.line}: `}${item.message}`,
|
|
92
|
+
),
|
|
93
|
+
...(typeof command.prompt_bytes === "number"
|
|
94
|
+
? { promptBytes: command.prompt_bytes }
|
|
95
|
+
: {}),
|
|
96
|
+
...(typeof command.prompt_file === "string"
|
|
97
|
+
? { promptFile: command.prompt_file }
|
|
98
|
+
: {}),
|
|
99
|
+
...(command.recipe_context !== undefined
|
|
100
|
+
? { recipeContext: command.recipe_context }
|
|
101
|
+
: {}),
|
|
102
|
+
sessionFile: file,
|
|
103
|
+
sessionTruncated: session.truncated,
|
|
104
|
+
...(typeof command.stage === "string"
|
|
105
|
+
? { stage: command.stage }
|
|
106
|
+
: {}),
|
|
107
|
+
}));
|
|
108
|
+
}),
|
|
109
|
+
)
|
|
110
|
+
.map((turn, index) => ({ ...turn, index: index + 1 }));
|
|
111
|
+
}
|
package/lib/file-state.ts
CHANGED
|
@@ -4,29 +4,28 @@
|
|
|
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
|
-
mkdirSync,
|
|
11
|
-
readFileSync,
|
|
12
|
-
realpathSync,
|
|
13
|
-
renameSync,
|
|
14
|
-
rmSync,
|
|
15
|
-
statSync,
|
|
16
|
-
unlinkSync,
|
|
17
|
-
writeFileSync,
|
|
18
|
-
} from "node:fs";
|
|
9
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
19
10
|
import { tmpdir } from "node:os";
|
|
20
11
|
import { basename, dirname, join, parse, resolve } from "node:path";
|
|
21
12
|
|
|
22
|
-
const FILE_MUTATION_LOCK_TIMEOUT_MS = 5000;
|
|
13
|
+
const FILE_MUTATION_LOCK_TIMEOUT_MS = process.platform === "win32" ? 15000 : 5000;
|
|
23
14
|
const FILE_MUTATION_LOCK_STALE_MS = 30000;
|
|
24
15
|
const FILE_MUTATION_LOCK_ROOT = join(tmpdir(), "pi-actors-file-locks");
|
|
25
16
|
|
|
26
17
|
function canonicalMutationPath(path: string): string {
|
|
27
18
|
const absolute = resolve(path);
|
|
28
|
-
|
|
29
|
-
|
|
19
|
+
try {
|
|
20
|
+
if (lstatSync(absolute).isSymbolicLink()) {
|
|
21
|
+
const target = realpathSync.native(absolute);
|
|
22
|
+
return process.platform === "win32" ? target.toLowerCase() : target;
|
|
23
|
+
}
|
|
24
|
+
} catch {
|
|
25
|
+
/* Materialization may race lock-key derivation; canonicalize through the parent. */
|
|
26
|
+
}
|
|
27
|
+
const suffix: string[] = [basename(absolute)];
|
|
28
|
+
let existing = dirname(absolute);
|
|
30
29
|
while (!existsSync(existing)) {
|
|
31
30
|
const parent = dirname(existing);
|
|
32
31
|
if (parent === existing || existing === parse(existing).root) break;
|
|
@@ -47,6 +46,16 @@ export function mutationLockPath(path: string): string {
|
|
|
47
46
|
return join(FILE_MUTATION_LOCK_ROOT, `${key}.lock`);
|
|
48
47
|
}
|
|
49
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
|
+
|
|
50
59
|
function lockOwnerStatus(lockPath: string): "alive" | "dead" | "unknown" {
|
|
51
60
|
try {
|
|
52
61
|
const owner = JSON.parse(
|
|
@@ -56,6 +65,7 @@ function lockOwnerStatus(lockPath: string): "alive" | "dead" | "unknown" {
|
|
|
56
65
|
if (!Number.isInteger(pid) || pid <= 0) return "unknown";
|
|
57
66
|
try {
|
|
58
67
|
process.kill(pid, 0);
|
|
68
|
+
if (Date.now() - statSync(lockPath).mtimeMs > 50 && isZombieProcess(pid)) return "dead";
|
|
59
69
|
return "alive";
|
|
60
70
|
} catch (error) {
|
|
61
71
|
return (error as NodeJS.ErrnoException).code === "ESRCH"
|
|
@@ -68,6 +78,7 @@ function lockOwnerStatus(lockPath: string): "alive" | "dead" | "unknown" {
|
|
|
68
78
|
}
|
|
69
79
|
|
|
70
80
|
export interface FileMutationLockOptions {
|
|
81
|
+
onBeforeLockPublish?(): void;
|
|
71
82
|
onBeforeReclaimRemove?(): void;
|
|
72
83
|
onContention?(): void;
|
|
73
84
|
onRemovalContention?(): void;
|
|
@@ -84,6 +95,28 @@ function readLockToken(lockPath: string): string | undefined {
|
|
|
84
95
|
}
|
|
85
96
|
}
|
|
86
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
|
+
|
|
87
120
|
function tryReclaimRemovalBoundary(reclaimPath: string): void {
|
|
88
121
|
if (!existsSync(reclaimPath)) return;
|
|
89
122
|
try {
|
|
@@ -95,7 +128,7 @@ function tryReclaimRemovalBoundary(reclaimPath: string): void {
|
|
|
95
128
|
(ownerStatus === "unknown" && age > FILE_MUTATION_LOCK_STALE_MS)) &&
|
|
96
129
|
readLockToken(reclaimPath) === inspectedToken
|
|
97
130
|
) {
|
|
98
|
-
|
|
131
|
+
removeLockBoundary(reclaimPath, inspectedToken);
|
|
99
132
|
}
|
|
100
133
|
} catch {
|
|
101
134
|
/* another contender changed the boundary */
|
|
@@ -108,29 +141,20 @@ function withRemovalBoundary(
|
|
|
108
141
|
): boolean {
|
|
109
142
|
const reclaimPath = `${lockPath}.reclaim`;
|
|
110
143
|
const token = randomUUID();
|
|
144
|
+
const pendingPath = prepareLockBoundary(reclaimPath, token);
|
|
111
145
|
try {
|
|
112
|
-
|
|
146
|
+
try { renameSync(pendingPath, reclaimPath); } catch {
|
|
147
|
+
tryReclaimRemovalBoundary(reclaimPath);
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
113
150
|
try {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
);
|
|
119
|
-
} catch (error) {
|
|
120
|
-
rmSync(reclaimPath, { recursive: true, force: true });
|
|
121
|
-
throw error;
|
|
151
|
+
action();
|
|
152
|
+
return true;
|
|
153
|
+
} finally {
|
|
154
|
+
removeLockBoundary(reclaimPath, token);
|
|
122
155
|
}
|
|
123
|
-
} catch {
|
|
124
|
-
tryReclaimRemovalBoundary(reclaimPath);
|
|
125
|
-
return false;
|
|
126
|
-
}
|
|
127
|
-
try {
|
|
128
|
-
action();
|
|
129
|
-
return true;
|
|
130
156
|
} finally {
|
|
131
|
-
|
|
132
|
-
rmSync(reclaimPath, { recursive: true, force: true });
|
|
133
|
-
}
|
|
157
|
+
try { rmSync(pendingPath, { recursive: true, force: true }); } catch {}
|
|
134
158
|
}
|
|
135
159
|
}
|
|
136
160
|
|
|
@@ -153,8 +177,7 @@ function tryReclaimMutationLock(
|
|
|
153
177
|
readLockToken(lockPath) === inspectedToken
|
|
154
178
|
) {
|
|
155
179
|
options.onBeforeReclaimRemove?.();
|
|
156
|
-
|
|
157
|
-
reclaimed = true;
|
|
180
|
+
reclaimed = removeLockBoundary(lockPath, inspectedToken);
|
|
158
181
|
}
|
|
159
182
|
})) {
|
|
160
183
|
return false;
|
|
@@ -170,34 +193,29 @@ export function acquireFileMutationLock(
|
|
|
170
193
|
const lockPath = mutationLockPath(path);
|
|
171
194
|
const deadline = Date.now() + FILE_MUTATION_LOCK_TIMEOUT_MS;
|
|
172
195
|
const token = randomUUID();
|
|
196
|
+
const pendingPath = prepareLockBoundary(lockPath, token, options.onBeforeLockPublish);
|
|
173
197
|
let contentionReported = false;
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
mkdirSync(lockPath);
|
|
198
|
+
try {
|
|
199
|
+
for (;;) {
|
|
177
200
|
try {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
`${JSON.stringify({ pid: process.pid, token, acquired_at: new Date().toISOString() })}\n`,
|
|
181
|
-
"utf8",
|
|
182
|
-
);
|
|
201
|
+
renameSync(pendingPath, lockPath);
|
|
202
|
+
break;
|
|
183
203
|
} catch (error) {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
throw new Error(`Timed out waiting for file mutation lock: ${canonicalMutationPath(path)}`, {
|
|
196
|
-
cause: error,
|
|
197
|
-
});
|
|
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);
|
|
198
215
|
}
|
|
199
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
|
200
216
|
}
|
|
217
|
+
} finally {
|
|
218
|
+
try { rmSync(pendingPath, { recursive: true, force: true }); } catch {}
|
|
201
219
|
}
|
|
202
220
|
let released = false;
|
|
203
221
|
return () => {
|
|
@@ -207,9 +225,7 @@ export function acquireFileMutationLock(
|
|
|
207
225
|
let removalContentionReported = false;
|
|
208
226
|
while (
|
|
209
227
|
!withRemovalBoundary(lockPath, () => {
|
|
210
|
-
|
|
211
|
-
rmSync(lockPath, { recursive: true, force: true });
|
|
212
|
-
}
|
|
228
|
+
removeLockBoundary(lockPath, token);
|
|
213
229
|
})
|
|
214
230
|
) {
|
|
215
231
|
if (!removalContentionReported) {
|
|
@@ -235,11 +251,11 @@ export function withFileMutationLock<T>(
|
|
|
235
251
|
}
|
|
236
252
|
}
|
|
237
253
|
|
|
238
|
-
export function
|
|
254
|
+
export function writeTextAtomic(path: string, content: string): void {
|
|
239
255
|
mkdirSync(dirname(path), { recursive: true });
|
|
240
256
|
const tempPath = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
|
|
241
257
|
try {
|
|
242
|
-
writeFileSync(tempPath,
|
|
258
|
+
writeFileSync(tempPath, content, "utf8");
|
|
243
259
|
renameSync(tempPath, path);
|
|
244
260
|
} catch (error) {
|
|
245
261
|
try {
|
|
@@ -250,3 +266,7 @@ export function writeJsonAtomic(path: string, value: unknown): void {
|
|
|
250
266
|
throw error;
|
|
251
267
|
}
|
|
252
268
|
}
|
|
269
|
+
|
|
270
|
+
export function writeJsonAtomic(path: string, value: unknown): void {
|
|
271
|
+
writeTextAtomic(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
272
|
+
}
|