@llblab/pi-actors 0.38.1 → 0.40.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 +11 -4
- package/BACKLOG.md +2 -11
- package/CHANGELOG.md +51 -0
- package/README.md +120 -131
- package/dist/index.js +6 -4
- package/dist/lib/async-runs.d.ts +4 -0
- package/dist/lib/async-runs.js +112 -17
- package/dist/lib/command-templates.d.ts +9 -0
- package/dist/lib/command-templates.js +92 -11
- package/dist/lib/config.js +0 -5
- package/dist/lib/execution.d.ts +31 -0
- package/dist/lib/execution.js +145 -12
- package/dist/lib/file-state.d.ts +1 -0
- package/dist/lib/file-state.js +91 -3
- package/dist/lib/observability.d.ts +1 -1
- package/dist/lib/observability.js +7 -4
- package/dist/lib/pi.d.ts +1 -1
- package/dist/lib/pi.js +2 -2
- package/dist/lib/prompts.d.ts +1 -2
- package/dist/lib/prompts.js +2 -3
- package/dist/lib/recipes-context.js +17 -9
- package/dist/lib/recipes-discovery.js +11 -5
- package/dist/lib/recipes-references.d.ts +1 -1
- package/dist/lib/recipes-references.js +4 -5
- package/dist/lib/recipes-usage.d.ts +2 -0
- package/dist/lib/recipes-usage.js +35 -21
- package/dist/lib/registry.d.ts +0 -2
- package/dist/lib/registry.js +33 -10
- package/dist/lib/runs-ownership.d.ts +7 -0
- package/dist/lib/runs-ownership.js +82 -0
- package/dist/lib/runs-process.d.ts +17 -2
- package/dist/lib/runs-process.js +99 -11
- package/dist/lib/runs-retention.d.ts +3 -0
- package/dist/lib/runs-retention.js +18 -3
- package/dist/lib/runs-start.d.ts +2 -2
- package/dist/lib/runs-start.js +51 -17
- package/dist/lib/runs-status.d.ts +1 -1
- package/dist/lib/runs-status.js +8 -6
- package/dist/lib/runtime.js +69 -13
- package/dist/lib/tools-inspect.d.ts +2 -0
- package/dist/lib/tools-inspect.js +39 -2
- package/dist/lib/tools-register.js +0 -1
- package/dist/lib/tools-spawn.js +3 -2
- package/dist/lib/tools.d.ts +1 -0
- package/dist/lib/tools.js +3 -0
- package/dist/pi-actors/index.js +1 -0
- package/dist/recipes/subagent-judge.json +2 -1
- package/dist/recipes/subagent-merge.json +2 -1
- package/dist/recipes/subagent-normalize.json +2 -1
- package/dist/recipes/subagent-review-coordinator.json +1 -1
- package/dist/recipes/subagent-review.json +2 -1
- package/dist/recipes/subagent-verify.json +2 -1
- package/dist/scripts/async-runner.mjs +274 -6
- package/dist/scripts/build-dist.mjs +14 -1
- package/dist/skills/actors/SKILL.md +11 -7
- package/dist/skills/swarm/SKILL.md +1 -1
- package/docs/actor-messages.md +1 -1
- package/docs/async-runs.md +14 -5
- package/docs/command-templates.md +4 -2
- package/docs/recipe-library.md +1 -0
- package/docs/template-recipes.md +5 -7
- package/docs/tool-registry.md +4 -2
- package/index.ts +18 -7
- package/lib/async-runs.ts +138 -19
- package/lib/command-templates.ts +132 -13
- package/lib/config.ts +0 -4
- package/lib/execution.ts +198 -13
- package/lib/file-state.ts +106 -3
- package/lib/observability.ts +11 -5
- package/lib/pi.ts +3 -3
- package/lib/prompts.ts +2 -4
- package/lib/recipes-context.ts +17 -9
- package/lib/recipes-discovery.ts +10 -5
- package/lib/recipes-references.ts +5 -6
- package/lib/recipes-usage.ts +36 -20
- package/lib/registry.ts +43 -13
- package/lib/runs-ownership.ts +117 -0
- package/lib/runs-process.ts +138 -16
- package/lib/runs-retention.ts +22 -2
- package/lib/runs-start.ts +89 -31
- package/lib/runs-status.ts +15 -6
- package/lib/runtime.ts +64 -12
- package/lib/tools-inspect.ts +46 -4
- package/lib/tools-register.ts +0 -3
- package/lib/tools-spawn.ts +5 -5
- package/lib/tools.ts +8 -0
- package/package.json +2 -2
- package/recipes/subagent-judge.json +2 -1
- package/recipes/subagent-merge.json +2 -1
- package/recipes/subagent-normalize.json +2 -1
- package/recipes/subagent-review-coordinator.json +1 -1
- package/recipes/subagent-review.json +2 -1
- package/recipes/subagent-verify.json +2 -1
- package/scripts/async-runner.mjs +274 -6
- package/scripts/build-dist.mjs +14 -1
- package/skills/actors/SKILL.md +11 -7
- package/skills/swarm/SKILL.md +1 -1
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { createHash } from "node:crypto";
|
|
7
7
|
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
-
import {
|
|
8
|
+
import { basename, dirname, join } from "node:path";
|
|
9
|
+
import { withFileMutationLock, writeJsonAtomic } from "./file-state.js";
|
|
9
10
|
function isRecord(value) {
|
|
10
11
|
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
11
12
|
}
|
|
@@ -28,25 +29,38 @@ function getRecipeFingerprint(raw) {
|
|
|
28
29
|
const { usage: _usage, ...content } = raw;
|
|
29
30
|
return createHash("sha256").update(stableStringify(content)).digest("hex");
|
|
30
31
|
}
|
|
32
|
+
export function getRecipeUsagePath(path) {
|
|
33
|
+
return join(dirname(path), ".usage", `${basename(path)}.json`);
|
|
34
|
+
}
|
|
35
|
+
export function readRecipeUsage(path) {
|
|
36
|
+
try {
|
|
37
|
+
const value = JSON.parse(readFileSync(getRecipeUsagePath(path), "utf8"));
|
|
38
|
+
return isRecord(value) ? value : undefined;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
31
44
|
function launchCounterKey(kind) {
|
|
32
45
|
return `${kind}_calls`;
|
|
33
46
|
}
|
|
34
47
|
export function recordRecipeLaunch(path, now = new Date(), kind = "direct") {
|
|
35
48
|
if (!existsSync(path))
|
|
36
49
|
return false;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
usage.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
+
const usagePath = getRecipeUsagePath(path);
|
|
51
|
+
return withFileMutationLock(usagePath, () => {
|
|
52
|
+
try {
|
|
53
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
54
|
+
if (!isRecord(raw))
|
|
55
|
+
return false;
|
|
56
|
+
const storedUsage = readRecipeUsage(path);
|
|
57
|
+
const usage = storedUsage ?? (isRecord(raw.usage) ? raw.usage : {});
|
|
58
|
+
const fingerprint = getRecipeFingerprint(raw);
|
|
59
|
+
const changed = typeof usage.fingerprint === "string" &&
|
|
60
|
+
usage.fingerprint !== fingerprint;
|
|
61
|
+
const nowIso = now.toISOString();
|
|
62
|
+
const counterKey = launchCounterKey(kind);
|
|
63
|
+
writeJsonAtomic(usagePath, {
|
|
50
64
|
...usage,
|
|
51
65
|
calls: (changed ? 0 : normalizeCalls(usage.calls)) + 1,
|
|
52
66
|
[counterKey]: (changed ? 0 : normalizeCalls(usage[counterKey])) + 1,
|
|
@@ -59,11 +73,11 @@ export function recordRecipeLaunch(path, now = new Date(), kind = "direct") {
|
|
|
59
73
|
reset_reason: "recipe content fingerprint changed",
|
|
60
74
|
}
|
|
61
75
|
: {}),
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
76
|
+
});
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
});
|
|
69
83
|
}
|
package/dist/lib/registry.d.ts
CHANGED
|
@@ -9,7 +9,6 @@ export interface RegisterToolInput {
|
|
|
9
9
|
name?: string;
|
|
10
10
|
description?: string;
|
|
11
11
|
async?: boolean;
|
|
12
|
-
state_dir?: string;
|
|
13
12
|
template?: CommandTemplates.CommandTemplateValue | null;
|
|
14
13
|
draft?: string;
|
|
15
14
|
args?: string;
|
|
@@ -24,7 +23,6 @@ export interface RegisterToolResultDetails {
|
|
|
24
23
|
draft?: string;
|
|
25
24
|
promoted?: boolean;
|
|
26
25
|
recipeName?: string;
|
|
27
|
-
state_dir?: string;
|
|
28
26
|
template?: CommandTemplates.CommandTemplateValue;
|
|
29
27
|
templateWarnings?: string[];
|
|
30
28
|
tool: string;
|
package/dist/lib/registry.js
CHANGED
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
* Zones: registry mutations, persistence, runtime activation
|
|
4
4
|
* Owns register/update/delete validation, persistence, runtime side effects, and result payloads
|
|
5
5
|
*/
|
|
6
|
-
import { existsSync, mkdirSync, unlinkSync } from "node:fs";
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
|
|
7
7
|
import { dirname, join, relative, resolve } from "node:path";
|
|
8
8
|
import * as CommandTemplates from "./command-templates.js";
|
|
9
|
+
import * as Config from "./config.js";
|
|
9
10
|
import * as ExecutionOutput from "./execution-output.js";
|
|
10
|
-
import { writeJsonAtomic } from "./file-state.js";
|
|
11
|
+
import { withFileMutationLock, writeJsonAtomic } from "./file-state.js";
|
|
11
12
|
import * as Identity from "./identity.js";
|
|
12
13
|
import * as Paths from "./paths.js";
|
|
13
14
|
import * as RecipesReferences from "./recipes-references.js";
|
|
@@ -94,7 +95,6 @@ function persistToolRecipe(deps, cfg) {
|
|
|
94
95
|
writeJsonAtomic(path, {
|
|
95
96
|
description: cfg.description,
|
|
96
97
|
...(cfg.recipe?.async !== undefined ? { async: cfg.recipe.async } : {}),
|
|
97
|
-
...(cfg.recipe?.state_dir ? { state_dir: cfg.recipe.state_dir } : {}),
|
|
98
98
|
...(cfg.storedArgs ? { args: cfg.storedArgs } : {}),
|
|
99
99
|
...(cfg.storedDefaults ? { defaults: cfg.storedDefaults } : {}),
|
|
100
100
|
...(cfg.recipe?.values ? { values: cfg.recipe.values } : {}),
|
|
@@ -125,6 +125,23 @@ function deleteTool(name, ctx, deps) {
|
|
|
125
125
|
details: { config: recipePath, tool: name },
|
|
126
126
|
};
|
|
127
127
|
}
|
|
128
|
+
function readAuthoritativeStoredTool(deps, name) {
|
|
129
|
+
const path = getToolRecipePath(deps, name);
|
|
130
|
+
if (!existsSync(path))
|
|
131
|
+
return { exists: false };
|
|
132
|
+
try {
|
|
133
|
+
const normalized = Config.normalizeStoredTool(name, JSON.parse(readFileSync(path, "utf8")), deps.reservedToolNames);
|
|
134
|
+
if (!normalized.cfg)
|
|
135
|
+
return { exists: true };
|
|
136
|
+
return {
|
|
137
|
+
exists: true,
|
|
138
|
+
tool: { ...normalized.cfg, sourcePath: path },
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return { exists: true };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
128
145
|
function getInputTemplate(value) {
|
|
129
146
|
if (typeof value === "string")
|
|
130
147
|
return value.trim();
|
|
@@ -167,9 +184,6 @@ function buildConfig(name, input, existing) {
|
|
|
167
184
|
? {
|
|
168
185
|
name: inputRecipe,
|
|
169
186
|
...(typeof input.async === "boolean" ? { async: input.async } : {}),
|
|
170
|
-
...(typeof input.state_dir === "string" && input.state_dir.trim()
|
|
171
|
-
? { state_dir: input.state_dir.trim() }
|
|
172
|
-
: {}),
|
|
173
187
|
template: finalTemplate,
|
|
174
188
|
...(input.values && typeof input.values === "object"
|
|
175
189
|
? { values: input.values }
|
|
@@ -219,7 +233,7 @@ function buildConfig(name, input, existing) {
|
|
|
219
233
|
...(storedDefaults !== undefined ? { storedDefaults } : {}),
|
|
220
234
|
};
|
|
221
235
|
}
|
|
222
|
-
|
|
236
|
+
function executeRegisterToolUnlocked(params, ctx, deps) {
|
|
223
237
|
const input = params;
|
|
224
238
|
if (!input.name)
|
|
225
239
|
return listTools(deps);
|
|
@@ -237,11 +251,12 @@ export async function executeRegisterTool(params, ctx, deps) {
|
|
|
237
251
|
if (templateProvided && (template === null || template === ""))
|
|
238
252
|
return deleteTool(name, ctx, deps);
|
|
239
253
|
const tools = deps.getTools();
|
|
240
|
-
const
|
|
254
|
+
const authoritative = readAuthoritativeStoredTool(deps, name);
|
|
255
|
+
const existing = authoritative.tool ?? tools.get(name);
|
|
241
256
|
const blocker = deps.getToolNameBlocker(name);
|
|
242
257
|
if (blocker)
|
|
243
258
|
throw new Error(ExecutionOutput.formatToolText(blocker));
|
|
244
|
-
if (existing && !input.update) {
|
|
259
|
+
if ((authoritative.exists || existing) && !input.update) {
|
|
245
260
|
throw new Error(ExecutionOutput.formatToolText(`Tool "${name}" already registered. Use update=true to overwrite.`));
|
|
246
261
|
}
|
|
247
262
|
if (template === undefined && !existing) {
|
|
@@ -275,10 +290,18 @@ export async function executeRegisterTool(params, ctx, deps) {
|
|
|
275
290
|
defaults: cfg.defaults,
|
|
276
291
|
...(cfg.recipe?.async !== undefined ? { async: cfg.recipe.async } : {}),
|
|
277
292
|
...(cfg.recipe?.name ? { recipeName: cfg.recipe.name } : {}),
|
|
278
|
-
...(cfg.recipe?.state_dir ? { state_dir: cfg.recipe.state_dir } : {}),
|
|
279
293
|
...(cfg.template ? { template: cfg.template } : {}),
|
|
280
294
|
...(templateWarnings.length > 0 ? { templateWarnings } : {}),
|
|
281
295
|
tool: name,
|
|
282
296
|
},
|
|
283
297
|
};
|
|
284
298
|
}
|
|
299
|
+
export async function executeRegisterTool(params, ctx, deps) {
|
|
300
|
+
const input = params;
|
|
301
|
+
if (!input.name)
|
|
302
|
+
return executeRegisterToolUnlocked(params, ctx, deps);
|
|
303
|
+
const name = Identity.normalizeToolName(input.name);
|
|
304
|
+
if (!name)
|
|
305
|
+
return executeRegisterToolUnlocked(params, ctx, deps);
|
|
306
|
+
return withFileMutationLock(getToolRecipePath(deps, name), () => executeRegisterToolUnlocked(params, ctx, deps));
|
|
307
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Async run state-directory ownership.
|
|
3
|
+
* Owns the marker proof required before launch and destructive retention.
|
|
4
|
+
*/
|
|
5
|
+
export declare const RUN_STATE_OWNERSHIP_FILE = ".pi-actors-run-state.json";
|
|
6
|
+
export declare function claimRunStateDirectory(stateDir: string, run: string): string;
|
|
7
|
+
export declare function assertOwnedRunStateDirectory(stateDir: string, run: string): string;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Async run state-directory ownership.
|
|
3
|
+
* Owns the marker proof required before launch and destructive retention.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, } from "node:fs";
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { join, resolve } from "node:path";
|
|
8
|
+
import { writeJsonAtomic } from "./file-state.js";
|
|
9
|
+
export const RUN_STATE_OWNERSHIP_FILE = ".pi-actors-run-state.json";
|
|
10
|
+
function markerPath(stateDir) {
|
|
11
|
+
return join(stateDir, RUN_STATE_OWNERSHIP_FILE);
|
|
12
|
+
}
|
|
13
|
+
function comparablePath(path) {
|
|
14
|
+
const resolved = resolve(path);
|
|
15
|
+
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
16
|
+
}
|
|
17
|
+
function assertCanonicalDirectory(stateDir) {
|
|
18
|
+
const resolved = resolve(stateDir);
|
|
19
|
+
if (lstatSync(resolved).isSymbolicLink()) {
|
|
20
|
+
throw new Error(`Run state directory cannot be a symlink: ${resolved}`);
|
|
21
|
+
}
|
|
22
|
+
const canonical = realpathSync(resolved);
|
|
23
|
+
if (comparablePath(canonical) !== comparablePath(resolved)) {
|
|
24
|
+
throw new Error(`Run state directory has an ambiguous symlink alias: ${resolved}`);
|
|
25
|
+
}
|
|
26
|
+
return resolved;
|
|
27
|
+
}
|
|
28
|
+
function readMarker(stateDir) {
|
|
29
|
+
try {
|
|
30
|
+
const value = JSON.parse(readFileSync(markerPath(stateDir), "utf8"));
|
|
31
|
+
if (value.version !== 1 ||
|
|
32
|
+
typeof value.run !== "string" ||
|
|
33
|
+
typeof value.state_dir !== "string" ||
|
|
34
|
+
typeof value.ownership_token !== "string" ||
|
|
35
|
+
!value.ownership_token) {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function assertMarkerMatches(marker, stateDir, run) {
|
|
45
|
+
if (!marker) {
|
|
46
|
+
throw new Error(`Run state ownership marker is missing or invalid: ${stateDir}`);
|
|
47
|
+
}
|
|
48
|
+
if (marker.state_dir !== stateDir || marker.run !== run) {
|
|
49
|
+
throw new Error(`Run state ownership marker does not match run ${run}: ${stateDir}`);
|
|
50
|
+
}
|
|
51
|
+
return marker;
|
|
52
|
+
}
|
|
53
|
+
export function claimRunStateDirectory(stateDir, run) {
|
|
54
|
+
const resolved = resolve(stateDir);
|
|
55
|
+
if (existsSync(resolved) && lstatSync(resolved).isSymbolicLink()) {
|
|
56
|
+
throw new Error(`Run state directory cannot be a symlink: ${resolved}`);
|
|
57
|
+
}
|
|
58
|
+
mkdirSync(resolved, { recursive: true });
|
|
59
|
+
const canonical = assertCanonicalDirectory(resolved);
|
|
60
|
+
const existing = readMarker(canonical);
|
|
61
|
+
if (existing) {
|
|
62
|
+
assertMarkerMatches(existing, canonical, run);
|
|
63
|
+
return canonical;
|
|
64
|
+
}
|
|
65
|
+
const existingEntries = readdirSync(canonical).filter((entry) => entry !== ".start.lock");
|
|
66
|
+
if (existingEntries.length > 0) {
|
|
67
|
+
throw new Error(`Refusing to claim existing non-run directory: ${canonical}`);
|
|
68
|
+
}
|
|
69
|
+
writeJsonAtomic(markerPath(canonical), {
|
|
70
|
+
created_at: new Date().toISOString(),
|
|
71
|
+
ownership_token: randomUUID(),
|
|
72
|
+
run,
|
|
73
|
+
state_dir: canonical,
|
|
74
|
+
version: 1,
|
|
75
|
+
});
|
|
76
|
+
return canonical;
|
|
77
|
+
}
|
|
78
|
+
export function assertOwnedRunStateDirectory(stateDir, run) {
|
|
79
|
+
const canonical = assertCanonicalDirectory(stateDir);
|
|
80
|
+
assertMarkerMatches(readMarker(canonical), canonical, run);
|
|
81
|
+
return canonical;
|
|
82
|
+
}
|
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Async run process identity helpers.
|
|
3
|
-
* Owns liveness
|
|
3
|
+
* Owns cross-platform liveness and stable runner process identity proofs.
|
|
4
4
|
*/
|
|
5
|
+
export interface RunProcessIdentity {
|
|
6
|
+
command: string;
|
|
7
|
+
cwd?: string;
|
|
8
|
+
platform: NodeJS.Platform;
|
|
9
|
+
start_time: string;
|
|
10
|
+
}
|
|
11
|
+
export type RunProcessIdentityStatus = "valid" | "dead_pid" | "owner_mismatch" | "unsupported_proof";
|
|
12
|
+
export interface RunProcessIdentityResult {
|
|
13
|
+
status: RunProcessIdentityStatus;
|
|
14
|
+
valid: boolean;
|
|
15
|
+
}
|
|
16
|
+
type ProcessIdentityReader = (pid: number, runtimePlatform: NodeJS.Platform) => RunProcessIdentity | undefined;
|
|
5
17
|
export declare function isAlive(pid: number): boolean;
|
|
6
|
-
export declare function
|
|
18
|
+
export declare function readProcessIdentity(pid: number, runtimePlatform?: NodeJS.Platform): RunProcessIdentity | undefined;
|
|
19
|
+
export declare function captureRunProcessIdentity(pid: number, cwd: string, stateDir: string, runnerPath: string, runtimePlatform?: NodeJS.Platform, reader?: ProcessIdentityReader): RunProcessIdentity | undefined;
|
|
20
|
+
export declare function verifyRunProcessIdentity(pid: number, expected: RunProcessIdentity | undefined, runtimePlatform?: NodeJS.Platform, reader?: ProcessIdentityReader, alive?: (pid: number) => boolean): RunProcessIdentityResult;
|
|
7
21
|
export declare function isWithinRunnerIdentityGrace(meta: Record<string, unknown>, graceMs: number): boolean;
|
|
22
|
+
export {};
|
package/dist/lib/runs-process.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Async run process identity helpers.
|
|
3
|
-
* Owns liveness
|
|
3
|
+
* Owns cross-platform liveness and stable runner process identity proofs.
|
|
4
4
|
*/
|
|
5
|
-
import {
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { existsSync, readFileSync, readlinkSync, realpathSync } from "node:fs";
|
|
6
7
|
import { platform } from "node:os";
|
|
7
8
|
import { resolve } from "node:path";
|
|
8
9
|
export function isAlive(pid) {
|
|
@@ -14,19 +15,106 @@ export function isAlive(pid) {
|
|
|
14
15
|
return false;
|
|
15
16
|
}
|
|
16
17
|
}
|
|
17
|
-
|
|
18
|
-
if (
|
|
19
|
-
return
|
|
18
|
+
function readLinuxIdentity(pid) {
|
|
19
|
+
if (!existsSync(`/proc/${pid}`))
|
|
20
|
+
return undefined;
|
|
20
21
|
try {
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
23
|
+
const close = stat.lastIndexOf(")");
|
|
24
|
+
const fields = stat.slice(close + 2).trim().split(/\s+/);
|
|
25
|
+
const startTime = fields[19];
|
|
26
|
+
if (!startTime)
|
|
27
|
+
return undefined;
|
|
28
|
+
return {
|
|
29
|
+
command: readFileSync(`/proc/${pid}/cmdline`, "utf8").replaceAll("\0", " ").trim(),
|
|
30
|
+
cwd: readlinkSync(`/proc/${pid}/cwd`),
|
|
31
|
+
platform: "linux",
|
|
32
|
+
start_time: startTime,
|
|
33
|
+
};
|
|
26
34
|
}
|
|
27
35
|
catch {
|
|
28
|
-
return
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function readDarwinIdentity(pid) {
|
|
40
|
+
const result = spawnSync("ps", ["-p", String(pid), "-o", "lstart=", "-o", "command="], { encoding: "utf8" });
|
|
41
|
+
if (result.status !== 0)
|
|
42
|
+
return undefined;
|
|
43
|
+
const match = result.stdout.trim().match(/^(.{24})\s+(.+)$/s);
|
|
44
|
+
if (!match)
|
|
45
|
+
return undefined;
|
|
46
|
+
return {
|
|
47
|
+
command: match[2].trim(),
|
|
48
|
+
platform: "darwin",
|
|
49
|
+
start_time: match[1].trim(),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function readWindowsIdentity(pid) {
|
|
53
|
+
const command = [
|
|
54
|
+
`$p = Get-CimInstance Win32_Process -Filter \"ProcessId = ${pid}\";`,
|
|
55
|
+
"if ($null -eq $p) { exit 3 };",
|
|
56
|
+
"$p | Select-Object CreationDate,ExecutablePath,CommandLine | ConvertTo-Json -Compress",
|
|
57
|
+
].join(" ");
|
|
58
|
+
const result = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command], { encoding: "utf8", windowsHide: true });
|
|
59
|
+
if (result.status !== 0)
|
|
60
|
+
return undefined;
|
|
61
|
+
try {
|
|
62
|
+
const value = JSON.parse(result.stdout);
|
|
63
|
+
if (typeof value.CreationDate !== "string")
|
|
64
|
+
return undefined;
|
|
65
|
+
const executable = typeof value.ExecutablePath === "string" ? value.ExecutablePath : "";
|
|
66
|
+
const commandLine = typeof value.CommandLine === "string" ? value.CommandLine : "";
|
|
67
|
+
return {
|
|
68
|
+
command: `${executable}\n${commandLine}`.trim(),
|
|
69
|
+
platform: "win32",
|
|
70
|
+
start_time: value.CreationDate,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export function readProcessIdentity(pid, runtimePlatform = platform()) {
|
|
78
|
+
if (runtimePlatform === "linux")
|
|
79
|
+
return readLinuxIdentity(pid);
|
|
80
|
+
if (runtimePlatform === "darwin")
|
|
81
|
+
return readDarwinIdentity(pid);
|
|
82
|
+
if (runtimePlatform === "win32")
|
|
83
|
+
return readWindowsIdentity(pid);
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
export function captureRunProcessIdentity(pid, cwd, stateDir, runnerPath, runtimePlatform = platform(), reader = readProcessIdentity) {
|
|
87
|
+
const identity = reader(pid, runtimePlatform);
|
|
88
|
+
if (!identity)
|
|
89
|
+
return undefined;
|
|
90
|
+
if (!identity.command.includes(runnerPath) || !identity.command.includes(stateDir)) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
const resolvedCwd = resolve(cwd);
|
|
94
|
+
const canonicalCwd = existsSync(resolvedCwd)
|
|
95
|
+
? realpathSync.native(resolvedCwd)
|
|
96
|
+
: resolvedCwd;
|
|
97
|
+
const expectedCwd = runtimePlatform === "win32" ? canonicalCwd.toLowerCase() : canonicalCwd;
|
|
98
|
+
const actualCwd = runtimePlatform === "win32" && identity.cwd
|
|
99
|
+
? identity.cwd.toLowerCase()
|
|
100
|
+
: identity.cwd;
|
|
101
|
+
if (actualCwd && actualCwd !== expectedCwd)
|
|
102
|
+
return undefined;
|
|
103
|
+
return identity;
|
|
104
|
+
}
|
|
105
|
+
export function verifyRunProcessIdentity(pid, expected, runtimePlatform = platform(), reader = readProcessIdentity, alive = isAlive) {
|
|
106
|
+
if (!alive(pid))
|
|
107
|
+
return { status: "dead_pid", valid: false };
|
|
108
|
+
if (!expected || expected.platform !== runtimePlatform) {
|
|
109
|
+
return { status: "unsupported_proof", valid: false };
|
|
29
110
|
}
|
|
111
|
+
const current = reader(pid, runtimePlatform);
|
|
112
|
+
if (!current)
|
|
113
|
+
return { status: "unsupported_proof", valid: false };
|
|
114
|
+
const valid = current.start_time === expected.start_time &&
|
|
115
|
+
current.command === expected.command &&
|
|
116
|
+
current.cwd === expected.cwd;
|
|
117
|
+
return { status: valid ? "valid" : "owner_mismatch", valid };
|
|
30
118
|
}
|
|
31
119
|
export function isWithinRunnerIdentityGrace(meta, graceMs) {
|
|
32
120
|
const createdAt = typeof meta.createdAt === "string" ? Date.parse(meta.createdAt) : NaN;
|
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
* Async run retention operations.
|
|
3
3
|
* Owns terminal-run archive and prune filesystem behavior.
|
|
4
4
|
*/
|
|
5
|
+
import { cpSync } from "node:fs";
|
|
5
6
|
export declare function archiveTerminalRun(status: Record<string, unknown>): Record<string, unknown>;
|
|
6
7
|
export declare function pruneTerminalRun(status: Record<string, unknown>, options?: {
|
|
7
8
|
preserveArtifacts?: boolean;
|
|
9
|
+
}, deps?: {
|
|
10
|
+
copyArtifact?: typeof cpSync;
|
|
8
11
|
}): Record<string, unknown>;
|
|
@@ -2,11 +2,21 @@
|
|
|
2
2
|
* Async run retention operations.
|
|
3
3
|
* Owns terminal-run archive and prune filesystem behavior.
|
|
4
4
|
*/
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
5
6
|
import { cpSync, mkdirSync, renameSync, rmSync } from "node:fs";
|
|
6
7
|
import { basename, dirname, join } from "node:path";
|
|
7
8
|
import { writeJsonAtomic } from "./file-state.js";
|
|
8
9
|
import { resolveArtifactManifest, } from "./runs-artifacts.js";
|
|
9
10
|
import { safeRunId } from "./runs-identity.js";
|
|
11
|
+
import { assertOwnedRunStateDirectory } from "./runs-ownership.js";
|
|
12
|
+
function retainedArtifactFilename(name, path) {
|
|
13
|
+
const readableName = name.replace(/[^A-Za-z0-9_.-]+/g, "_") || "artifact";
|
|
14
|
+
const identity = createHash("sha256")
|
|
15
|
+
.update(`${name}\0${path}`)
|
|
16
|
+
.digest("hex")
|
|
17
|
+
.slice(0, 8);
|
|
18
|
+
return `${readableName}-${identity}--${basename(path)}`;
|
|
19
|
+
}
|
|
10
20
|
function archivePathFor(run, stateDir) {
|
|
11
21
|
const archiveRoot = join(dirname(stateDir), "archived");
|
|
12
22
|
mkdirSync(archiveRoot, { recursive: true });
|
|
@@ -15,6 +25,7 @@ function archivePathFor(run, stateDir) {
|
|
|
15
25
|
export function archiveTerminalRun(status) {
|
|
16
26
|
const stateDir = String(status.state_dir);
|
|
17
27
|
const run = String(status.run ?? basename(stateDir));
|
|
28
|
+
assertOwnedRunStateDirectory(stateDir, run);
|
|
18
29
|
const archiveDir = archivePathFor(run, stateDir);
|
|
19
30
|
renameSync(stateDir, archiveDir);
|
|
20
31
|
mkdirSync(stateDir, { recursive: true });
|
|
@@ -29,9 +40,10 @@ export function archiveTerminalRun(status) {
|
|
|
29
40
|
writeJsonAtomic(join(stateDir, "archive-tombstone.json"), tombstone);
|
|
30
41
|
return tombstone;
|
|
31
42
|
}
|
|
32
|
-
export function pruneTerminalRun(status, options = {}) {
|
|
43
|
+
export function pruneTerminalRun(status, options = {}, deps = {}) {
|
|
33
44
|
const stateDir = String(status.state_dir);
|
|
34
45
|
const run = String(status.run ?? basename(stateDir));
|
|
46
|
+
assertOwnedRunStateDirectory(stateDir, run);
|
|
35
47
|
const manifest = resolveArtifactManifest(status.artifacts);
|
|
36
48
|
const preserved = {};
|
|
37
49
|
if (options.preserveArtifacts && manifest) {
|
|
@@ -40,8 +52,11 @@ export function pruneTerminalRun(status, options = {}) {
|
|
|
40
52
|
for (const [name, artifact] of Object.entries(manifest)) {
|
|
41
53
|
if (!artifact.exists)
|
|
42
54
|
continue;
|
|
43
|
-
const target = join(preserveRoot,
|
|
44
|
-
cpSync(artifact.path, target, {
|
|
55
|
+
const target = join(preserveRoot, retainedArtifactFilename(name, artifact.path));
|
|
56
|
+
(deps.copyArtifact ?? cpSync)(artifact.path, target, {
|
|
57
|
+
force: true,
|
|
58
|
+
preserveTimestamps: true,
|
|
59
|
+
});
|
|
45
60
|
preserved[name] = target;
|
|
46
61
|
}
|
|
47
62
|
}
|
package/dist/lib/runs-start.d.ts
CHANGED
|
@@ -4,6 +4,6 @@
|
|
|
4
4
|
* preparation before a new runner process is spawned.
|
|
5
5
|
*/
|
|
6
6
|
export type RunJsonReader = (path: string) => Record<string, unknown> | undefined;
|
|
7
|
-
export declare function assertNoActiveRunState(stateDir: string, readJson: RunJsonReader,
|
|
7
|
+
export declare function assertNoActiveRunState(stateDir: string, readJson: RunJsonReader, _runnerPath: string): void;
|
|
8
8
|
export declare function acquireStateStartLock(stateDir: string): () => void;
|
|
9
|
-
export declare function prepareStateDirForStart(stateDir: string, readJson: RunJsonReader,
|
|
9
|
+
export declare function prepareStateDirForStart(stateDir: string, readJson: RunJsonReader, _runnerPath: string): void;
|
package/dist/lib/runs-start.js
CHANGED
|
@@ -3,33 +3,64 @@
|
|
|
3
3
|
* Owns active-run reuse checks, start lock acquisition, and safe state-dir
|
|
4
4
|
* preparation before a new runner process is spawned.
|
|
5
5
|
*/
|
|
6
|
-
import { mkdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
7
7
|
import { join } from "node:path";
|
|
8
|
-
import { isAlive,
|
|
8
|
+
import { isAlive, readProcessIdentity, verifyRunProcessIdentity, } from "./runs-process.js";
|
|
9
9
|
const START_LOCK_MAX_AGE_MS = 5 * 60 * 1000;
|
|
10
|
-
export function assertNoActiveRunState(stateDir, readJson,
|
|
10
|
+
export function assertNoActiveRunState(stateDir, readJson, _runnerPath) {
|
|
11
11
|
const meta = readJson(join(stateDir, "run.json"));
|
|
12
12
|
if (!meta)
|
|
13
13
|
return;
|
|
14
14
|
const pid = Number(meta.pid || 0);
|
|
15
15
|
const cwd = String(meta.cwd ?? "");
|
|
16
|
-
if (!pid || !isAlive(pid)
|
|
16
|
+
if (!pid || !isAlive(pid))
|
|
17
17
|
return;
|
|
18
|
-
|
|
18
|
+
const identity = verifyRunProcessIdentity(pid, meta.process_identity);
|
|
19
|
+
if (identity.status === "owner_mismatch") {
|
|
20
|
+
throw new Error(`Run state process identity does not match the live pid: ${String(meta.run ?? stateDir)}. Refusing to reuse the state directory while pid ${pid} is alive.`);
|
|
21
|
+
}
|
|
22
|
+
if (identity.status === "unsupported_proof") {
|
|
23
|
+
throw new Error(`Run state process identity proof is unavailable: ${String(meta.run ?? stateDir)}. Refusing to reuse the state directory while pid ${pid} is alive.`);
|
|
24
|
+
}
|
|
25
|
+
if (identity.valid) {
|
|
26
|
+
throw new Error(`Run state already has an active owned process: ${String(meta.run ?? stateDir)}. Stop it before reusing the same run_id or state_dir.`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function writeStartLockOwner(lockDir, recovered = false) {
|
|
30
|
+
const processIdentity = readProcessIdentity(process.pid);
|
|
31
|
+
writeFileSync(join(lockDir, "owner.json"), `${JSON.stringify({
|
|
32
|
+
pid: process.pid,
|
|
33
|
+
createdAt: new Date().toISOString(),
|
|
34
|
+
...(processIdentity ? { process_identity: processIdentity } : {}),
|
|
35
|
+
...(recovered ? { recovered: true } : {}),
|
|
36
|
+
})}\n`, "utf8");
|
|
37
|
+
}
|
|
38
|
+
function isStartLockOwnerProvenDead(lockDir) {
|
|
39
|
+
try {
|
|
40
|
+
const owner = JSON.parse(readFileSync(join(lockDir, "owner.json"), "utf8"));
|
|
41
|
+
const pid = Number(owner.pid || 0);
|
|
42
|
+
if (!pid)
|
|
43
|
+
return false;
|
|
44
|
+
return verifyRunProcessIdentity(pid, owner.process_identity).status === "dead_pid";
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
19
49
|
}
|
|
20
50
|
export function acquireStateStartLock(stateDir) {
|
|
21
51
|
const lockDir = join(stateDir, ".start.lock");
|
|
22
52
|
try {
|
|
23
53
|
mkdirSync(lockDir);
|
|
24
|
-
|
|
54
|
+
writeStartLockOwner(lockDir);
|
|
25
55
|
}
|
|
26
56
|
catch (error) {
|
|
27
57
|
try {
|
|
28
58
|
const stat = statSync(lockDir);
|
|
29
|
-
if (Date.now() - stat.mtimeMs > START_LOCK_MAX_AGE_MS
|
|
59
|
+
if (Date.now() - stat.mtimeMs > START_LOCK_MAX_AGE_MS &&
|
|
60
|
+
isStartLockOwnerProvenDead(lockDir)) {
|
|
30
61
|
rmSync(lockDir, { recursive: true, force: true });
|
|
31
62
|
mkdirSync(lockDir);
|
|
32
|
-
|
|
63
|
+
writeStartLockOwner(lockDir, true);
|
|
33
64
|
return () => rmSync(lockDir, { recursive: true, force: true });
|
|
34
65
|
}
|
|
35
66
|
}
|
|
@@ -40,17 +71,20 @@ export function acquireStateStartLock(stateDir) {
|
|
|
40
71
|
}
|
|
41
72
|
return () => rmSync(lockDir, { recursive: true, force: true });
|
|
42
73
|
}
|
|
43
|
-
export function prepareStateDirForStart(stateDir, readJson,
|
|
74
|
+
export function prepareStateDirForStart(stateDir, readJson, _runnerPath) {
|
|
44
75
|
const existing = readJson(join(stateDir, "run.json"));
|
|
45
76
|
const existingPid = Number(existing?.pid || 0);
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
77
|
+
if (existingPid && isAlive(existingPid)) {
|
|
78
|
+
const identity = verifyRunProcessIdentity(existingPid, existing?.process_identity);
|
|
79
|
+
if (identity.status === "owner_mismatch") {
|
|
80
|
+
throw new Error(`Run state process identity does not match the live pid: ${String(existing?.run ?? stateDir)}. Refusing to prepare the state directory while pid ${existingPid} is alive.`);
|
|
81
|
+
}
|
|
82
|
+
if (identity.status === "unsupported_proof") {
|
|
83
|
+
throw new Error(`Run state process identity proof is unavailable: ${String(existing?.run ?? stateDir)}. Refusing to prepare the state directory while pid ${existingPid} is alive.`);
|
|
84
|
+
}
|
|
85
|
+
if (identity.valid) {
|
|
86
|
+
throw new Error(`Run state already has an active owned process: ${String(existing?.run ?? stateDir)}. Stop it before restarting.`);
|
|
87
|
+
}
|
|
54
88
|
}
|
|
55
89
|
for (const file of [
|
|
56
90
|
"events.jsonl",
|
|
@@ -3,6 +3,6 @@
|
|
|
3
3
|
* Owns status derivation from run state files plus bounded log/event tails.
|
|
4
4
|
*/
|
|
5
5
|
export type RunJsonReader = (path: string) => Record<string, unknown> | undefined;
|
|
6
|
-
export declare function buildRunStatus(stateDir: string, runOrDir: string, meta: Record<string, unknown>, readJson: RunJsonReader,
|
|
6
|
+
export declare function buildRunStatus(stateDir: string, runOrDir: string, meta: Record<string, unknown>, readJson: RunJsonReader, _runnerPath: string, runnerIdentityGraceMs: number): Record<string, unknown>;
|
|
7
7
|
export declare function tailFile(path: string, lines: number): string;
|
|
8
8
|
export declare function tailLines(path: string, lines: number): string[];
|