@llblab/pi-actors 0.39.0 → 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 +10 -3
  2. package/BACKLOG.md +2 -11
  3. package/CHANGELOG.md +45 -0
  4. package/README.md +4 -4
  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
@@ -10,6 +10,7 @@ import { join } from "node:path";
10
10
  import * as CommandTemplates from "./command-templates.js";
11
11
  import * as ModelContext from "./model-context.js";
12
12
  import * as RecipesReferences from "./recipes-references.js";
13
+ import * as RecipesUsage from "./recipes-usage.js";
13
14
  import * as Schema from "./schema.js";
14
15
  function assertToolSafeRepeatConfig(config, argTypes, defaults) {
15
16
  if (typeof config === "string" || config === undefined || config === null)
@@ -242,8 +243,13 @@ export function discoverRecipeSources(sources) {
242
243
  export function discoverRecipes(roots) {
243
244
  return discoverRecipeSources(roots.map((root) => ({ root })));
244
245
  }
245
- function recipeUsage(config) {
246
- const usage = config?.usage;
246
+ function recipeUsage(entry) {
247
+ const stored = entry.mutableUsage
248
+ ? RecipesUsage.readRecipeUsage(entry.path)
249
+ : undefined;
250
+ if (stored)
251
+ return stored;
252
+ const usage = entry.config?.usage;
247
253
  return usage && typeof usage === "object" && !Array.isArray(usage)
248
254
  ? usage
249
255
  : undefined;
@@ -273,7 +279,7 @@ function cleanupRecommendation(entry) {
273
279
  actions: ["keep disabled", "delete", "archive"],
274
280
  };
275
281
  }
276
- const usage = recipeUsage(entry.config);
282
+ const usage = recipeUsage(entry);
277
283
  const calls = Number(usage?.calls ?? 0);
278
284
  if (entry.mutableUsage && entry.tool && calls === 0) {
279
285
  return {
@@ -581,8 +587,8 @@ export function summarizeDiscovery(result) {
581
587
  }
582
588
  : {}),
583
589
  ...(entry.config?.imports ? { imports: entry.config.imports } : {}),
584
- ...(recipeUsage(entry.config)
585
- ? { usage: recipeUsage(entry.config) }
590
+ ...(recipeUsage(entry)
591
+ ? { usage: recipeUsage(entry) }
586
592
  : {}),
587
593
  }))
588
594
  .sort((a, b) => a.id.localeCompare(b.id)),
@@ -39,6 +39,7 @@ export interface TemplateRecipeDefinition {
39
39
  when?: boolean | string;
40
40
  timeout?: number | string;
41
41
  delay?: number | string;
42
+ accept_output?: "review_evidence";
42
43
  output?: string;
43
44
  artifacts?: Record<string, string>;
44
45
  mailbox?: TemplateRecipeMailbox;
@@ -52,7 +53,6 @@ export interface TemplateRecipeDefinition {
52
53
  }
53
54
  export interface TemplateRecipeConfig extends TemplateRecipeDefinition {
54
55
  async?: boolean;
55
- state_dir?: string;
56
56
  }
57
57
  export interface TemplateRecipeContextRecord {
58
58
  alias?: string;
@@ -141,6 +141,7 @@ function getRecipeCommandTemplate(raw) {
141
141
  "when",
142
142
  "timeout",
143
143
  "delay",
144
+ "accept_output",
144
145
  "output",
145
146
  "retry",
146
147
  "failure",
@@ -695,11 +696,6 @@ export function readResolvedRecipeConfig(file, stack = [], options = {}) {
695
696
  : delegated?.async === false
696
697
  ? { async: false }
697
698
  : {}),
698
- ...(typeof substituted.state_dir === "string"
699
- ? { state_dir: substituted.state_dir }
700
- : typeof delegated?.state_dir === "string"
701
- ? { state_dir: delegated.state_dir }
702
- : {}),
703
699
  ...(Object.keys(imports).length > 0
704
700
  ? { imports: getRecipeImports(raw) }
705
701
  : {}),
@@ -736,6 +732,9 @@ export function readResolvedRecipeConfig(file, stack = [], options = {}) {
736
732
  typeof substituted.delay === "string"
737
733
  ? { delay: substituted.delay }
738
734
  : {}),
735
+ ...(substituted.accept_output === "review_evidence"
736
+ ? { accept_output: substituted.accept_output }
737
+ : {}),
739
738
  ...(typeof substituted.output === "string"
740
739
  ? { output: substituted.output }
741
740
  : {}),
@@ -4,4 +4,6 @@
4
4
  * Owns lightweight launch counters for user-owned recipe files
5
5
  */
6
6
  export type RecipeLaunchKind = "direct" | "spawn" | "tool";
7
+ export declare function getRecipeUsagePath(path: string): string;
8
+ export declare function readRecipeUsage(path: string): Record<string, unknown> | undefined;
7
9
  export declare function recordRecipeLaunch(path: string, now?: Date, kind?: RecipeLaunchKind): boolean;
@@ -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;