@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.
Files changed (97) hide show
  1. package/AGENTS.md +11 -4
  2. package/BACKLOG.md +2 -11
  3. package/CHANGELOG.md +51 -0
  4. package/README.md +120 -131
  5. package/dist/index.js +6 -4
  6. package/dist/lib/async-runs.d.ts +4 -0
  7. package/dist/lib/async-runs.js +112 -17
  8. package/dist/lib/command-templates.d.ts +9 -0
  9. package/dist/lib/command-templates.js +92 -11
  10. package/dist/lib/config.js +0 -5
  11. package/dist/lib/execution.d.ts +31 -0
  12. package/dist/lib/execution.js +145 -12
  13. package/dist/lib/file-state.d.ts +1 -0
  14. package/dist/lib/file-state.js +91 -3
  15. package/dist/lib/observability.d.ts +1 -1
  16. package/dist/lib/observability.js +7 -4
  17. package/dist/lib/pi.d.ts +1 -1
  18. package/dist/lib/pi.js +2 -2
  19. package/dist/lib/prompts.d.ts +1 -2
  20. package/dist/lib/prompts.js +2 -3
  21. package/dist/lib/recipes-context.js +17 -9
  22. package/dist/lib/recipes-discovery.js +11 -5
  23. package/dist/lib/recipes-references.d.ts +1 -1
  24. package/dist/lib/recipes-references.js +4 -5
  25. package/dist/lib/recipes-usage.d.ts +2 -0
  26. package/dist/lib/recipes-usage.js +35 -21
  27. package/dist/lib/registry.d.ts +0 -2
  28. package/dist/lib/registry.js +33 -10
  29. package/dist/lib/runs-ownership.d.ts +7 -0
  30. package/dist/lib/runs-ownership.js +82 -0
  31. package/dist/lib/runs-process.d.ts +17 -2
  32. package/dist/lib/runs-process.js +99 -11
  33. package/dist/lib/runs-retention.d.ts +3 -0
  34. package/dist/lib/runs-retention.js +18 -3
  35. package/dist/lib/runs-start.d.ts +2 -2
  36. package/dist/lib/runs-start.js +51 -17
  37. package/dist/lib/runs-status.d.ts +1 -1
  38. package/dist/lib/runs-status.js +8 -6
  39. package/dist/lib/runtime.js +69 -13
  40. package/dist/lib/tools-inspect.d.ts +2 -0
  41. package/dist/lib/tools-inspect.js +39 -2
  42. package/dist/lib/tools-register.js +0 -1
  43. package/dist/lib/tools-spawn.js +3 -2
  44. package/dist/lib/tools.d.ts +1 -0
  45. package/dist/lib/tools.js +3 -0
  46. package/dist/pi-actors/index.js +1 -0
  47. package/dist/recipes/subagent-judge.json +2 -1
  48. package/dist/recipes/subagent-merge.json +2 -1
  49. package/dist/recipes/subagent-normalize.json +2 -1
  50. package/dist/recipes/subagent-review-coordinator.json +1 -1
  51. package/dist/recipes/subagent-review.json +2 -1
  52. package/dist/recipes/subagent-verify.json +2 -1
  53. package/dist/scripts/async-runner.mjs +274 -6
  54. package/dist/scripts/build-dist.mjs +14 -1
  55. package/dist/skills/actors/SKILL.md +11 -7
  56. package/dist/skills/swarm/SKILL.md +1 -1
  57. package/docs/actor-messages.md +1 -1
  58. package/docs/async-runs.md +14 -5
  59. package/docs/command-templates.md +4 -2
  60. package/docs/recipe-library.md +1 -0
  61. package/docs/template-recipes.md +5 -7
  62. package/docs/tool-registry.md +4 -2
  63. package/index.ts +18 -7
  64. package/lib/async-runs.ts +138 -19
  65. package/lib/command-templates.ts +132 -13
  66. package/lib/config.ts +0 -4
  67. package/lib/execution.ts +198 -13
  68. package/lib/file-state.ts +106 -3
  69. package/lib/observability.ts +11 -5
  70. package/lib/pi.ts +3 -3
  71. package/lib/prompts.ts +2 -4
  72. package/lib/recipes-context.ts +17 -9
  73. package/lib/recipes-discovery.ts +10 -5
  74. package/lib/recipes-references.ts +5 -6
  75. package/lib/recipes-usage.ts +36 -20
  76. package/lib/registry.ts +43 -13
  77. package/lib/runs-ownership.ts +117 -0
  78. package/lib/runs-process.ts +138 -16
  79. package/lib/runs-retention.ts +22 -2
  80. package/lib/runs-start.ts +89 -31
  81. package/lib/runs-status.ts +15 -6
  82. package/lib/runtime.ts +64 -12
  83. package/lib/tools-inspect.ts +46 -4
  84. package/lib/tools-register.ts +0 -3
  85. package/lib/tools-spawn.ts +5 -5
  86. package/lib/tools.ts +8 -0
  87. package/package.json +2 -2
  88. package/recipes/subagent-judge.json +2 -1
  89. package/recipes/subagent-merge.json +2 -1
  90. package/recipes/subagent-normalize.json +2 -1
  91. package/recipes/subagent-review-coordinator.json +1 -1
  92. package/recipes/subagent-review.json +2 -1
  93. package/recipes/subagent-verify.json +2 -1
  94. package/scripts/async-runner.mjs +274 -6
  95. package/scripts/build-dist.mjs +14 -1
  96. package/skills/actors/SKILL.md +11 -7
  97. 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 { writeJsonAtomic } from "./file-state.js";
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
- try {
38
- const raw = JSON.parse(readFileSync(path, "utf8"));
39
- if (!isRecord(raw))
40
- return false;
41
- const usage = isRecord(raw.usage) ? raw.usage : {};
42
- const fingerprint = getRecipeFingerprint(raw);
43
- const changed = typeof usage.fingerprint === "string" &&
44
- usage.fingerprint !== fingerprint;
45
- const nowIso = now.toISOString();
46
- const counterKey = launchCounterKey(kind);
47
- writeJsonAtomic(path, {
48
- ...raw,
49
- usage: {
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
- return true;
65
- }
66
- catch {
67
- return false;
68
- }
76
+ });
77
+ return true;
78
+ }
79
+ catch {
80
+ return false;
81
+ }
82
+ });
69
83
  }
@@ -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;
@@ -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
- export async function executeRegisterTool(params, ctx, deps) {
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 existing = tools.get(name);
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 checks and Linux runner identity matching for run-owned processes.
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 pidMatchesRun(pid: number, cwd: string, stateDir: string, runnerPath: string): boolean;
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 {};
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Async run process identity helpers.
3
- * Owns liveness checks and Linux runner identity matching for run-owned processes.
3
+ * Owns cross-platform liveness and stable runner process identity proofs.
4
4
  */
5
- import { existsSync, readFileSync, readlinkSync } from "node:fs";
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
- export function pidMatchesRun(pid, cwd, stateDir, runnerPath) {
18
- if (platform() !== "linux" || !existsSync(`/proc/${pid}`))
19
- return isAlive(pid);
18
+ function readLinuxIdentity(pid) {
19
+ if (!existsSync(`/proc/${pid}`))
20
+ return undefined;
20
21
  try {
21
- const procCwd = readlinkSync(`/proc/${pid}/cwd`);
22
- const cmdline = readFileSync(`/proc/${pid}/cmdline`, "utf8");
23
- return (procCwd === resolve(cwd) &&
24
- cmdline.includes(runnerPath) &&
25
- cmdline.includes(stateDir));
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 false;
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, basename(artifact.path));
44
- cpSync(artifact.path, target, { force: true });
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
  }
@@ -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, runnerPath: string): void;
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, runnerPath: string): void;
9
+ export declare function prepareStateDirForStart(stateDir: string, readJson: RunJsonReader, _runnerPath: string): void;
@@ -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, pidMatchesRun } from "./runs-process.js";
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, runnerPath) {
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) || !pidMatchesRun(pid, cwd, stateDir, runnerPath))
16
+ if (!pid || !isAlive(pid))
17
17
  return;
18
- 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.`);
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
- writeFileSync(join(lockDir, "owner.json"), `${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })}\n`, "utf8");
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
- writeFileSync(join(lockDir, "owner.json"), `${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString(), recovered: true })}\n`, "utf8");
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, runnerPath) {
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
- const existingCwd = typeof existing?.cwd === "string" ? existing.cwd : undefined;
47
- const existingResult = readJson(join(stateDir, "result.json"));
48
- if (!existingResult &&
49
- existingPid &&
50
- existingCwd &&
51
- isAlive(existingPid) &&
52
- pidMatchesRun(existingPid, existingCwd, stateDir, runnerPath)) {
53
- throw new Error(`Run state already has an active owned process: ${String(existing?.run ?? stateDir)}. Stop it before restarting.`);
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, runnerPath: string, runnerIdentityGraceMs: number): Record<string, unknown>;
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[];