@llblab/pi-actors 0.41.1 → 0.42.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/AGENTS.md +11 -6
  2. package/BACKLOG.md +1 -65
  3. package/CHANGELOG.md +25 -0
  4. package/README.md +24 -4
  5. package/dist/index.js +25 -121
  6. package/dist/lib/async-runs.d.ts +37 -5
  7. package/dist/lib/async-runs.js +181 -51
  8. package/dist/lib/automatic-review-runtime.d.ts +18 -0
  9. package/dist/lib/automatic-review-runtime.js +96 -0
  10. package/dist/lib/command-templates.js +1 -1
  11. package/dist/lib/draft-consolidation-transaction.d.ts +65 -0
  12. package/dist/lib/draft-consolidation-transaction.js +610 -0
  13. package/dist/lib/draft-consolidation.d.ts +35 -0
  14. package/dist/lib/draft-consolidation.js +126 -0
  15. package/dist/lib/draft-review.d.ts +56 -0
  16. package/dist/lib/draft-review.js +254 -0
  17. package/dist/lib/draft-sleep.d.ts +65 -0
  18. package/dist/lib/draft-sleep.js +468 -0
  19. package/dist/lib/file-state.d.ts +8 -1
  20. package/dist/lib/file-state.js +115 -18
  21. package/dist/lib/inspector-actions.d.ts +16 -0
  22. package/dist/lib/inspector-actions.js +57 -0
  23. package/dist/lib/inspector-command.d.ts +7 -0
  24. package/dist/lib/inspector-command.js +37 -0
  25. package/dist/lib/inspector-overlay.d.ts +23 -3
  26. package/dist/lib/inspector-overlay.js +327 -70
  27. package/dist/lib/inspector.d.ts +9 -0
  28. package/dist/lib/inspector.js +49 -1
  29. package/dist/lib/observability.d.ts +13 -0
  30. package/dist/lib/observability.js +160 -6
  31. package/dist/lib/paths.d.ts +7 -0
  32. package/dist/lib/paths.js +28 -0
  33. package/dist/lib/recipes-discovery.js +32 -24
  34. package/dist/lib/recipes-usage.d.ts +18 -6
  35. package/dist/lib/recipes-usage.js +445 -34
  36. package/dist/lib/review-control.d.ts +14 -0
  37. package/dist/lib/review-control.js +111 -0
  38. package/dist/lib/review-diagnostics.d.ts +11 -0
  39. package/dist/lib/review-diagnostics.js +148 -0
  40. package/dist/lib/review-projection.d.ts +14 -0
  41. package/dist/lib/review-projection.js +170 -0
  42. package/dist/lib/run-ui-runtime.d.ts +18 -0
  43. package/dist/lib/run-ui-runtime.js +123 -0
  44. package/dist/lib/runs-artifacts.d.ts +1 -1
  45. package/dist/lib/runs-artifacts.js +1 -1
  46. package/dist/lib/runs-control.d.ts +10 -2
  47. package/dist/lib/runs-control.js +37 -7
  48. package/dist/lib/runs-identity.d.ts +1 -1
  49. package/dist/lib/runs-identity.js +1 -1
  50. package/dist/lib/runs-index.d.ts +11 -2
  51. package/dist/lib/runs-index.js +46 -23
  52. package/dist/lib/runs-mailbox.d.ts +1 -1
  53. package/dist/lib/runs-mailbox.js +1 -1
  54. package/dist/lib/runs-messages.d.ts +1 -1
  55. package/dist/lib/runs-messages.js +1 -1
  56. package/dist/lib/runs-outbox.d.ts +1 -1
  57. package/dist/lib/runs-outbox.js +1 -1
  58. package/dist/lib/runs-ownership.d.ts +1 -1
  59. package/dist/lib/runs-ownership.js +18 -4
  60. package/dist/lib/runs-parent-teardown.d.ts +51 -0
  61. package/dist/lib/runs-parent-teardown.js +172 -0
  62. package/dist/lib/runs-process.d.ts +1 -1
  63. package/dist/lib/runs-process.js +5 -4
  64. package/dist/lib/runs-retention.d.ts +1 -1
  65. package/dist/lib/runs-retention.js +1 -1
  66. package/dist/lib/runs-start.d.ts +5 -3
  67. package/dist/lib/runs-start.js +7 -48
  68. package/dist/lib/runs-status.d.ts +5 -3
  69. package/dist/lib/runs-status.js +7 -6
  70. package/dist/lib/runtime.d.ts +7 -1
  71. package/dist/lib/runtime.js +32 -18
  72. package/dist/lib/tool-review-lineage-transaction.d.ts +27 -0
  73. package/dist/lib/tool-review-lineage-transaction.js +597 -0
  74. package/dist/lib/tool-review-lineage.d.ts +24 -0
  75. package/dist/lib/tool-review-lineage.js +98 -0
  76. package/dist/lib/tool-review-scheduler.d.ts +80 -0
  77. package/dist/lib/tool-review-scheduler.js +494 -0
  78. package/dist/lib/tool-review-transaction.d.ts +50 -0
  79. package/dist/lib/tool-review-transaction.js +362 -0
  80. package/dist/lib/tool-review.d.ts +56 -0
  81. package/dist/lib/tool-review.js +197 -0
  82. package/dist/lib/tools-inspect.js +28 -4
  83. package/dist/lib/tools-local.js +21 -4
  84. package/dist/lib/tools-message.d.ts +1 -0
  85. package/dist/lib/tools-message.js +29 -17
  86. package/dist/lib/tools-spawn.js +14 -2
  87. package/dist/lib/tools.d.ts +1 -0
  88. package/dist/lib/tools.js +1 -0
  89. package/dist/recipes/draft-review.json +24 -0
  90. package/dist/recipes/tool-review.json +24 -0
  91. package/dist/scripts/async-runner.mjs +9 -9
  92. package/dist/scripts/build-dist.mjs +6 -1
  93. package/dist/scripts/conformance.mjs +6 -1
  94. package/dist/scripts/recipe-utils.mjs +3 -3
  95. package/dist/scripts/release-gates.mjs +165 -0
  96. package/dist/skills/actors/SKILL.md +9 -8
  97. package/dist/skills/swarm/SKILL.md +1 -1
  98. package/docs/actor-inspector.md +20 -11
  99. package/docs/async-runs.md +16 -2
  100. package/docs/recipe-library.md +7 -3
  101. package/docs/template-recipes.md +4 -11
  102. package/docs/tool-registry.md +13 -4
  103. package/index.ts +27 -142
  104. package/lib/async-runs.ts +283 -67
  105. package/lib/automatic-review-runtime.ts +135 -0
  106. package/lib/command-templates.ts +1 -1
  107. package/lib/draft-consolidation-transaction.ts +821 -0
  108. package/lib/draft-consolidation.ts +181 -0
  109. package/lib/draft-review.ts +325 -0
  110. package/lib/draft-sleep.ts +576 -0
  111. package/lib/file-state.ts +143 -19
  112. package/lib/inspector-actions.ts +79 -0
  113. package/lib/inspector-command.ts +54 -0
  114. package/lib/inspector-overlay.ts +377 -91
  115. package/lib/inspector.ts +73 -1
  116. package/lib/observability.ts +194 -5
  117. package/lib/paths.ts +43 -0
  118. package/lib/recipes-discovery.ts +34 -26
  119. package/lib/recipes-usage.ts +569 -40
  120. package/lib/review-control.ts +137 -0
  121. package/lib/review-diagnostics.ts +164 -0
  122. package/lib/review-projection.ts +200 -0
  123. package/lib/run-ui-runtime.ts +153 -0
  124. package/lib/runs-artifacts.ts +1 -1
  125. package/lib/runs-control.ts +69 -6
  126. package/lib/runs-identity.ts +1 -1
  127. package/lib/runs-index.ts +57 -21
  128. package/lib/runs-mailbox.ts +1 -1
  129. package/lib/runs-messages.ts +1 -1
  130. package/lib/runs-outbox.ts +1 -1
  131. package/lib/runs-ownership.ts +23 -4
  132. package/lib/runs-parent-teardown.ts +257 -0
  133. package/lib/runs-process.ts +5 -4
  134. package/lib/runs-retention.ts +1 -1
  135. package/lib/runs-start.ts +13 -68
  136. package/lib/runs-status.ts +17 -8
  137. package/lib/runtime.ts +34 -17
  138. package/lib/tool-review-lineage-transaction.ts +881 -0
  139. package/lib/tool-review-lineage.ts +145 -0
  140. package/lib/tool-review-scheduler.ts +635 -0
  141. package/lib/tool-review-transaction.ts +563 -0
  142. package/lib/tool-review.ts +270 -0
  143. package/lib/tools-inspect.ts +35 -4
  144. package/lib/tools-local.ts +29 -4
  145. package/lib/tools-message.ts +45 -30
  146. package/lib/tools-spawn.ts +22 -2
  147. package/lib/tools.ts +5 -0
  148. package/package.json +5 -3
  149. package/recipes/draft-review.json +24 -0
  150. package/recipes/tool-review.json +24 -0
  151. package/scripts/async-runner.mjs +9 -9
  152. package/scripts/build-dist.mjs +6 -1
  153. package/scripts/conformance.mjs +6 -1
  154. package/scripts/recipe-utils.mjs +3 -3
  155. package/scripts/release-gates.mjs +165 -0
  156. package/skills/actors/SKILL.md +9 -8
  157. package/skills/swarm/SKILL.md +1 -1
@@ -1,12 +1,17 @@
1
1
  /**
2
2
  * Async run process control primitives.
3
- * Owns platform signal planning, owned-process signalling, and terminal control markers.
3
+ * Owns: platform signal planning, owned-process signalling, and terminal control markers.
4
4
  */
5
5
 
6
6
  import { spawnSync } from "node:child_process";
7
7
  import { join } from "node:path";
8
8
 
9
9
  import { writeJsonAtomic } from "./file-state.ts";
10
+ import {
11
+ verifyRunProcessIdentity,
12
+ type RunProcessIdentity,
13
+ type RunProcessIdentityResult,
14
+ } from "./runs-process.ts";
10
15
 
11
16
  export interface RunProcessSignalPlan {
12
17
  args?: string[];
@@ -34,14 +39,58 @@ export function getRunProcessSignalPlan(
34
39
  return { signalTarget: "processGroup" };
35
40
  }
36
41
 
42
+ export interface RunProcessSignalDeps {
43
+ killProcess?: typeof process.kill;
44
+ runtimePlatform?: NodeJS.Platform;
45
+ spawnProcess?: typeof spawnSync;
46
+ verifyIdentity?: (
47
+ pid: number,
48
+ expected: RunProcessIdentity,
49
+ runtimePlatform: NodeJS.Platform,
50
+ ) => RunProcessIdentityResult;
51
+ }
52
+
37
53
  export function signalOwnedRunProcess(
38
54
  pid: number,
39
55
  signal: NodeJS.Signals,
56
+ expectedIdentity?: RunProcessIdentity,
57
+ deps: RunProcessSignalDeps = {},
40
58
  ): RunProcessSignalPlan {
41
- const plan = getRunProcessSignalPlan(pid, signal);
59
+ const runtimePlatform = deps.runtimePlatform ?? process.platform;
60
+ if (expectedIdentity) {
61
+ const proof = (deps.verifyIdentity ?? verifyRunProcessIdentity)(
62
+ pid,
63
+ expectedIdentity,
64
+ runtimePlatform,
65
+ );
66
+ if (!proof.valid) {
67
+ throw new Error(
68
+ `Run process identity changed before signaling: ${proof.status.replaceAll("_", " ")}`,
69
+ );
70
+ }
71
+ }
72
+ const plan = getRunProcessSignalPlan(pid, signal, runtimePlatform);
42
73
  if (plan.command && plan.args) {
43
- const result = spawnSync(plan.command, plan.args, { encoding: "utf8" });
74
+ const spawnProcess = deps.spawnProcess ?? spawnSync;
75
+ let result = spawnProcess(plan.command, plan.args, { encoding: "utf8" });
76
+ if (
77
+ runtimePlatform === "win32" &&
78
+ result.status !== 0 &&
79
+ !plan.args.includes("/F")
80
+ ) {
81
+ result = spawnProcess(plan.command, [...plan.args, "/F"], {
82
+ encoding: "utf8",
83
+ });
84
+ }
44
85
  if (result.status !== 0) {
86
+ if (expectedIdentity) {
87
+ const finalProof = (deps.verifyIdentity ?? verifyRunProcessIdentity)(
88
+ pid,
89
+ expectedIdentity,
90
+ runtimePlatform,
91
+ );
92
+ if (finalProof.status === "dead_pid") return plan;
93
+ }
45
94
  throw new Error(
46
95
  result.stderr?.trim() ||
47
96
  result.stdout?.trim() ||
@@ -50,11 +99,25 @@ export function signalOwnedRunProcess(
50
99
  }
51
100
  return plan;
52
101
  }
102
+ const killProcess = deps.killProcess ?? process.kill.bind(process);
53
103
  try {
54
- process.kill(-pid, signal);
104
+ killProcess(-pid, signal);
55
105
  return { signalTarget: "processGroup" };
56
- } catch {
57
- process.kill(pid, signal);
106
+ } catch (error) {
107
+ if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
108
+ if (expectedIdentity) {
109
+ const fallbackProof = (deps.verifyIdentity ?? verifyRunProcessIdentity)(
110
+ pid,
111
+ expectedIdentity,
112
+ runtimePlatform,
113
+ );
114
+ if (!fallbackProof.valid) {
115
+ throw new Error(
116
+ `Run process identity changed before pid fallback: ${fallbackProof.status.replaceAll("_", " ")}`,
117
+ );
118
+ }
119
+ }
120
+ killProcess(pid, signal);
58
121
  return { signalTarget: "process" };
59
122
  }
60
123
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Async run identity helpers.
3
- * Owns run id normalization shared by run lifecycle and retention paths.
3
+ * Owns: run id normalization shared by run lifecycle and retention paths.
4
4
  */
5
5
 
6
6
  export function safeRunId(value: string | undefined): string {
package/lib/runs-index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Async run state index.
3
- * Owns recursive run-state discovery, index rebuild/read, and status filtering.
3
+ * Owns: recursive run-state discovery, index rebuild/read, and status filtering.
4
4
  */
5
5
 
6
6
  import { existsSync, mkdirSync, readdirSync } from "node:fs";
@@ -30,27 +30,63 @@ function matchesStatusFilter(
30
30
  return status === filter;
31
31
  }
32
32
 
33
- export function listRunStateDirs(
33
+ export interface RunStateDiscoveryIssue {
34
+ path: string;
35
+ reason: "depth_truncated" | "unreadable";
36
+ }
37
+
38
+ export interface RunStateDiscoveryResult {
39
+ issues: RunStateDiscoveryIssue[];
40
+ stateDirs: string[];
41
+ }
42
+
43
+ export function discoverRunStateDirs(
34
44
  stateRoot: string,
35
- depth = 0,
36
- seen = new Set<string>(),
37
- ): string[] {
38
- if (!existsSync(stateRoot) || seen.has(resolve(stateRoot))) return [];
39
- seen.add(resolve(stateRoot));
40
- let entries: import("node:fs").Dirent[];
41
- try {
42
- entries = readdirSync(stateRoot, { withFileTypes: true });
43
- } catch {
44
- return [];
45
- }
46
- const result: string[] = [];
47
- for (const entry of entries) {
48
- if (!entry.isDirectory()) continue;
49
- const child = join(stateRoot, entry.name);
50
- if (existsSync(join(child, "run.json"))) result.push(child);
51
- if (depth + 1 < 8) result.push(...listRunStateDirs(child, depth + 1, seen));
52
- }
53
- return result;
45
+ maxDepth = 8,
46
+ ): RunStateDiscoveryResult {
47
+ const issues: RunStateDiscoveryIssue[] = [];
48
+ const stateDirs: string[] = [];
49
+ const seen = new Set<string>();
50
+ const visit = (directory: string, depth: number): void => {
51
+ const canonical = resolve(directory);
52
+ if (!existsSync(directory) || seen.has(canonical)) return;
53
+ seen.add(canonical);
54
+ let entries: import("node:fs").Dirent[];
55
+ try {
56
+ entries = readdirSync(directory, { withFileTypes: true });
57
+ } catch {
58
+ issues.push({ path: directory, reason: "unreadable" });
59
+ return;
60
+ }
61
+ for (const entry of entries) {
62
+ if (!entry.isDirectory()) continue;
63
+ const child = join(directory, entry.name);
64
+ if (existsSync(join(child, "run.json"))) stateDirs.push(child);
65
+ if (depth + 1 < maxDepth) visit(child, depth + 1);
66
+ else {
67
+ try {
68
+ if (
69
+ readdirSync(child, { withFileTypes: true }).some((item) =>
70
+ item.isDirectory(),
71
+ )
72
+ ) {
73
+ issues.push({ path: child, reason: "depth_truncated" });
74
+ }
75
+ } catch {
76
+ issues.push({ path: child, reason: "unreadable" });
77
+ }
78
+ }
79
+ }
80
+ };
81
+ visit(stateRoot, 0);
82
+ return {
83
+ issues: issues.sort((left, right) => left.path.localeCompare(right.path)),
84
+ stateDirs: [...new Set(stateDirs)].sort(),
85
+ };
86
+ }
87
+
88
+ export function listRunStateDirs(stateRoot: string): string[] {
89
+ return discoverRunStateDirs(stateRoot).stateDirs;
54
90
  }
55
91
 
56
92
  function runIndexPath(stateRoot: string): string {
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Async run mailbox state.
3
- * Owns durable run inbox records, claim locks, and handler status transitions.
3
+ * Owns: durable run inbox records, claim locks, and handler status transitions.
4
4
  */
5
5
 
6
6
  import { randomUUID } from "node:crypto";
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Async run message delivery.
3
- * Owns run control endpoint resolution, inbox enqueueing, wake notification,
3
+ * Owns: run control endpoint resolution, inbox enqueueing, wake notification,
4
4
  * transport writes, and delivery receipts.
5
5
  */
6
6
 
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Async run outbox events.
3
- * Owns outbox event normalization, parsing, and append payload formatting.
3
+ * Owns: outbox event normalization, parsing, and append payload formatting.
4
4
  */
5
5
 
6
6
  export type RunOutboxDelivery = "log" | "notify" | "followup";
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Async run state-directory ownership.
3
- * Owns the marker proof required before launch and destructive retention.
3
+ * Owns: the marker proof required before launch and destructive retention.
4
4
  */
5
5
 
6
6
  import {
@@ -12,7 +12,8 @@ import {
12
12
  realpathSync,
13
13
  } from "node:fs";
14
14
  import { randomUUID } from "node:crypto";
15
- import { join, resolve } from "node:path";
15
+ import { tmpdir } from "node:os";
16
+ import { isAbsolute, join, relative, resolve } from "node:path";
16
17
 
17
18
  import { writeJsonAtomic } from "./file-state.ts";
18
19
 
@@ -35,13 +36,31 @@ function comparablePath(path: string): string {
35
36
  return process.platform === "win32" ? resolved.toLowerCase() : resolved;
36
37
  }
37
38
 
39
+ function isSystemTempRootAlias(resolved: string, canonical: string): boolean {
40
+ const tempRoot = resolve(tmpdir());
41
+ const relativeStateDir = relative(tempRoot, resolved);
42
+ if (
43
+ relativeStateDir === ".." ||
44
+ relativeStateDir.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) ||
45
+ isAbsolute(relativeStateDir)
46
+ ) {
47
+ return false;
48
+ }
49
+ const canonicalTempRoot = realpathSync.native(tempRoot);
50
+ const expectedCanonical = resolve(canonicalTempRoot, relativeStateDir);
51
+ return comparablePath(canonical) === comparablePath(expectedCanonical);
52
+ }
53
+
38
54
  function assertCanonicalDirectory(stateDir: string): string {
39
55
  const resolved = resolve(stateDir);
40
56
  if (lstatSync(resolved).isSymbolicLink()) {
41
57
  throw new Error(`Run state directory cannot be a symlink: ${resolved}`);
42
58
  }
43
- const canonical = realpathSync(resolved);
44
- if (comparablePath(canonical) !== comparablePath(resolved)) {
59
+ const canonical = realpathSync.native(resolved);
60
+ if (
61
+ comparablePath(canonical) !== comparablePath(resolved) &&
62
+ !isSystemTempRootAlias(resolved, canonical)
63
+ ) {
45
64
  throw new Error(`Run state directory has an ambiguous symlink alias: ${resolved}`);
46
65
  }
47
66
  return resolved;
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Parent-session actor teardown.
3
+ * Owns: exact-owner running-run selection, kill revalidation, bounded outcomes, and partial-failure continuation.
4
+ */
5
+
6
+ export interface ParentRunTeardownCandidate {
7
+ ownerId: string;
8
+ run: string;
9
+ runInstanceId?: string;
10
+ stateDir: string;
11
+ }
12
+
13
+ export type ParentRunTeardownOutcome =
14
+ | "killed"
15
+ | "skipped"
16
+ | "failed";
17
+
18
+ export interface ParentRunTeardownAttempt extends ParentRunTeardownCandidate {
19
+ outcome: ParentRunTeardownOutcome;
20
+ reason?: string;
21
+ }
22
+
23
+ export interface ParentRunTeardownDiscoveryFailure {
24
+ path: string;
25
+ reason: string;
26
+ }
27
+
28
+ export interface ParentRunTeardownResult {
29
+ attempted: number;
30
+ discoveryFailed: number;
31
+ discoveryFailures: ParentRunTeardownDiscoveryFailure[];
32
+ failed: number;
33
+ killed: number;
34
+ skipped: number;
35
+ attempts: ParentRunTeardownAttempt[];
36
+ }
37
+
38
+ export interface ParentRunTeardownSummary extends ParentRunTeardownResult {
39
+ attemptsOmitted: number;
40
+ discoveryFailuresOmitted: number;
41
+ ownerId: string;
42
+ trigger: string;
43
+ ts: string;
44
+ version: 1;
45
+ }
46
+
47
+ const SUMMARY_ENTRY_LIMIT = 200;
48
+ const SUMMARY_TEXT_LIMIT = 500;
49
+
50
+ function boundedSummaryText(value: string): string {
51
+ return value.length > SUMMARY_TEXT_LIMIT
52
+ ? `${value.slice(0, SUMMARY_TEXT_LIMIT - 1)}…`
53
+ : value;
54
+ }
55
+
56
+ export function buildBoundedParentTeardownSummary(
57
+ result: ParentRunTeardownResult,
58
+ ownerId: string,
59
+ trigger: string,
60
+ ts: string,
61
+ ): ParentRunTeardownSummary {
62
+ const attempts = result.attempts.slice(0, SUMMARY_ENTRY_LIMIT).map((attempt) => ({
63
+ ownerId: boundedSummaryText(attempt.ownerId),
64
+ outcome: attempt.outcome,
65
+ ...(attempt.reason ? { reason: boundedSummaryText(attempt.reason) } : {}),
66
+ run: boundedSummaryText(attempt.run),
67
+ ...(attempt.runInstanceId
68
+ ? { runInstanceId: boundedSummaryText(attempt.runInstanceId) }
69
+ : {}),
70
+ stateDir: boundedSummaryText(attempt.stateDir),
71
+ }));
72
+ const discoveryFailures = result.discoveryFailures
73
+ .slice(0, SUMMARY_ENTRY_LIMIT)
74
+ .map((failure) => ({
75
+ path: boundedSummaryText(failure.path),
76
+ reason: boundedSummaryText(failure.reason),
77
+ }));
78
+ return {
79
+ attempted: result.attempted,
80
+ attempts,
81
+ attemptsOmitted: Math.max(0, result.attempts.length - attempts.length),
82
+ discoveryFailed: result.discoveryFailed,
83
+ discoveryFailures,
84
+ discoveryFailuresOmitted: Math.max(
85
+ 0,
86
+ result.discoveryFailures.length - discoveryFailures.length,
87
+ ),
88
+ failed: result.failed,
89
+ killed: result.killed,
90
+ ownerId: boundedSummaryText(ownerId),
91
+ skipped: result.skipped,
92
+ trigger: boundedSummaryText(trigger),
93
+ ts: boundedSummaryText(ts),
94
+ version: 1,
95
+ };
96
+ }
97
+
98
+ export interface ParentRunTeardownDeps {
99
+ getRunStatus: (stateDir: string) => Record<string, unknown>;
100
+ killRun: (
101
+ stateDir: string,
102
+ expected: { ownerId: string; runInstanceId: string },
103
+ ) => Record<string, unknown>;
104
+ listRunStatuses: () =>
105
+ | Array<Record<string, unknown>>
106
+ | {
107
+ failures: ParentRunTeardownDiscoveryFailure[];
108
+ statuses: Array<Record<string, unknown>>;
109
+ };
110
+ recordAttempt: (attempt: ParentRunTeardownAttempt) => void;
111
+ }
112
+
113
+ function errorMessage(error: unknown): string {
114
+ return error instanceof Error ? error.message : String(error);
115
+ }
116
+
117
+ function candidateFrom(
118
+ ownerId: string,
119
+ status: Record<string, unknown>,
120
+ ): ParentRunTeardownCandidate | undefined {
121
+ if (
122
+ status.ownerId !== ownerId ||
123
+ status.status !== "running" ||
124
+ typeof status.run !== "string" ||
125
+ typeof status.state_dir !== "string"
126
+ ) {
127
+ return undefined;
128
+ }
129
+ return {
130
+ ownerId,
131
+ run: status.run,
132
+ ...(typeof status.run_instance_id === "string"
133
+ ? { runInstanceId: status.run_instance_id }
134
+ : {}),
135
+ stateDir: status.state_dir,
136
+ };
137
+ }
138
+
139
+ export function selectParentRunTeardownCandidates(
140
+ ownerId: string | undefined,
141
+ statuses: Array<Record<string, unknown>>,
142
+ ): ParentRunTeardownCandidate[] {
143
+ if (!ownerId) return [];
144
+ const byStateDir = new Map<string, ParentRunTeardownCandidate>();
145
+ for (const status of statuses) {
146
+ const candidate = candidateFrom(ownerId, status);
147
+ if (candidate && !byStateDir.has(candidate.stateDir)) {
148
+ byStateDir.set(candidate.stateDir, candidate);
149
+ }
150
+ }
151
+ return [...byStateDir.values()].sort((left, right) =>
152
+ left.run.localeCompare(right.run),
153
+ );
154
+ }
155
+
156
+ function recordAttempt(
157
+ deps: ParentRunTeardownDeps,
158
+ attempt: ParentRunTeardownAttempt,
159
+ ): ParentRunTeardownAttempt {
160
+ try {
161
+ deps.recordAttempt(attempt);
162
+ return attempt;
163
+ } catch (error) {
164
+ return {
165
+ ...attempt,
166
+ outcome: "failed",
167
+ reason: `${attempt.reason ? `${attempt.reason}; ` : ""}evidence: ${errorMessage(error)}`,
168
+ };
169
+ }
170
+ }
171
+
172
+ export function teardownParentRuns(
173
+ ownerId: string | undefined,
174
+ deps: ParentRunTeardownDeps,
175
+ ): ParentRunTeardownResult {
176
+ let discovery: ReturnType<ParentRunTeardownDeps["listRunStatuses"]>;
177
+ try {
178
+ const listed = deps.listRunStatuses();
179
+ discovery = Array.isArray(listed)
180
+ ? { failures: [], statuses: listed }
181
+ : listed;
182
+ } catch (error) {
183
+ discovery = {
184
+ failures: [{ path: "<state-root>", reason: errorMessage(error) }],
185
+ statuses: [],
186
+ };
187
+ }
188
+ const candidates = selectParentRunTeardownCandidates(
189
+ ownerId,
190
+ discovery.statuses,
191
+ );
192
+ const attempts: ParentRunTeardownAttempt[] = [];
193
+ for (const candidate of candidates) {
194
+ let attempt: ParentRunTeardownAttempt;
195
+ try {
196
+ const current = deps.getRunStatus(candidate.stateDir);
197
+ if (!candidate.runInstanceId) {
198
+ attempt = {
199
+ ...candidate,
200
+ outcome: "failed",
201
+ reason: "run generation unavailable",
202
+ };
203
+ } else if (current.ownerId !== candidate.ownerId) {
204
+ attempt = {
205
+ ...candidate,
206
+ outcome: "skipped",
207
+ reason: "ownership changed",
208
+ };
209
+ } else if (current.run_instance_id !== candidate.runInstanceId) {
210
+ attempt = {
211
+ ...candidate,
212
+ outcome: "skipped",
213
+ reason: "run generation changed",
214
+ };
215
+ } else if (current.status !== "running") {
216
+ attempt = {
217
+ ...candidate,
218
+ outcome: "skipped",
219
+ reason: "already terminal",
220
+ };
221
+ } else {
222
+ const killed = deps.killRun(candidate.stateDir, {
223
+ ownerId: candidate.ownerId,
224
+ runInstanceId: candidate.runInstanceId,
225
+ });
226
+ attempt = killed.killed === true
227
+ ? { ...candidate, outcome: "killed" }
228
+ : {
229
+ ...candidate,
230
+ outcome: "failed",
231
+ reason:
232
+ typeof killed.reason === "string"
233
+ ? killed.reason
234
+ : "kill rejected",
235
+ };
236
+ }
237
+ } catch (error) {
238
+ attempt = {
239
+ ...candidate,
240
+ outcome: "failed",
241
+ reason: errorMessage(error),
242
+ };
243
+ }
244
+ attempts.push(recordAttempt(deps, attempt));
245
+ }
246
+ return {
247
+ attempted: candidates.length,
248
+ discoveryFailed: discovery.failures.length,
249
+ discoveryFailures: discovery.failures,
250
+ failed:
251
+ attempts.filter((attempt) => attempt.outcome === "failed").length +
252
+ discovery.failures.length,
253
+ killed: attempts.filter((attempt) => attempt.outcome === "killed").length,
254
+ skipped: attempts.filter((attempt) => attempt.outcome === "skipped").length,
255
+ attempts,
256
+ };
257
+ }
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * Async run process identity helpers.
3
- * Owns cross-platform liveness and stable runner process identity proofs.
3
+ * Owns: cross-platform liveness and stable runner process identity proofs.
4
4
  */
5
5
 
6
6
  import { spawnSync } from "node:child_process";
7
7
  import { existsSync, readFileSync, readlinkSync, realpathSync } from "node:fs";
8
8
  import { platform } from "node:os";
9
- import { resolve } from "node:path";
9
+ import { posix, win32 } from "node:path";
10
10
 
11
11
  export interface RunProcessIdentity {
12
12
  command: string;
@@ -125,8 +125,9 @@ export function captureRunProcessIdentity(
125
125
  if (!identity.command.includes(runnerPath) || !identity.command.includes(stateDir)) {
126
126
  return undefined;
127
127
  }
128
- const resolvedCwd = resolve(cwd);
129
- const canonicalCwd = existsSync(resolvedCwd)
128
+ const pathApi = runtimePlatform === "win32" ? win32 : posix;
129
+ const resolvedCwd = pathApi.resolve(cwd);
130
+ const canonicalCwd = runtimePlatform === platform() && existsSync(resolvedCwd)
130
131
  ? realpathSync.native(resolvedCwd)
131
132
  : resolvedCwd;
132
133
  const expectedCwd =
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Async run retention operations.
3
- * Owns terminal-run archive and prune filesystem behavior.
3
+ * Owns: terminal-run archive and prune filesystem behavior.
4
4
  */
5
5
 
6
6
  import { createHash } from "node:crypto";
package/lib/runs-start.ts CHANGED
@@ -1,28 +1,23 @@
1
1
  /**
2
2
  * Async run start guards.
3
- * Owns active-run reuse checks, start lock acquisition, and safe state-dir
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
6
 
7
- import {
8
- mkdirSync,
9
- readFileSync,
10
- rmSync,
11
- statSync,
12
- writeFileSync,
13
- } from "node:fs";
7
+ import { rmSync } from "node:fs";
14
8
  import { join } from "node:path";
15
9
 
10
+ import {
11
+ acquireFileMutationLock,
12
+ type FileMutationLockOptions,
13
+ } from "./file-state.ts";
16
14
  import {
17
15
  isAlive,
18
- readProcessIdentity,
19
16
  verifyRunProcessIdentity,
20
17
  type RunProcessIdentity,
21
18
  } from "./runs-process.ts";
22
19
 
23
- const START_LOCK_MAX_AGE_MS = 5 * 60 * 1000;
24
-
25
- export type RunJsonReader = (path: string) => Record<string, unknown> | undefined;
20
+ type RunJsonReader = (path: string) => Record<string, unknown> | undefined;
26
21
 
27
22
  export function assertNoActiveRunState(
28
23
  stateDir: string,
@@ -55,62 +50,11 @@ export function assertNoActiveRunState(
55
50
  }
56
51
  }
57
52
 
58
- function writeStartLockOwner(lockDir: string, recovered = false): void {
59
- const processIdentity = readProcessIdentity(process.pid);
60
- writeFileSync(
61
- join(lockDir, "owner.json"),
62
- `${JSON.stringify({
63
- pid: process.pid,
64
- createdAt: new Date().toISOString(),
65
- ...(processIdentity ? { process_identity: processIdentity } : {}),
66
- ...(recovered ? { recovered: true } : {}),
67
- })}\n`,
68
- "utf8",
69
- );
70
- }
71
-
72
- function isStartLockOwnerProvenDead(lockDir: string): boolean {
73
- try {
74
- const owner = JSON.parse(
75
- readFileSync(join(lockDir, "owner.json"), "utf8"),
76
- ) as Record<string, unknown>;
77
- const pid = Number(owner.pid || 0);
78
- if (!pid) return false;
79
- return verifyRunProcessIdentity(
80
- pid,
81
- owner.process_identity as RunProcessIdentity | undefined,
82
- ).status === "dead_pid";
83
- } catch {
84
- return false;
85
- }
86
- }
87
-
88
- export function acquireStateStartLock(stateDir: string): () => void {
89
- const lockDir = join(stateDir, ".start.lock");
90
- try {
91
- mkdirSync(lockDir);
92
- writeStartLockOwner(lockDir);
93
- } catch (error) {
94
- try {
95
- const stat = statSync(lockDir);
96
- if (
97
- Date.now() - stat.mtimeMs > START_LOCK_MAX_AGE_MS &&
98
- isStartLockOwnerProvenDead(lockDir)
99
- ) {
100
- rmSync(lockDir, { recursive: true, force: true });
101
- mkdirSync(lockDir);
102
- writeStartLockOwner(lockDir, true);
103
- return () => rmSync(lockDir, { recursive: true, force: true });
104
- }
105
- } catch {
106
- // Keep the original lock acquisition error below.
107
- }
108
- throw new Error(
109
- `Run state is already being started: ${stateDir}. Retry after the current start finishes.`,
110
- { cause: error },
111
- );
112
- }
113
- return () => rmSync(lockDir, { recursive: true, force: true });
53
+ export function acquireStateStartLock(
54
+ stateDir: string,
55
+ options: FileMutationLockOptions = {},
56
+ ): () => void {
57
+ return acquireFileMutationLock(join(stateDir, ".lifecycle"), options);
114
58
  }
115
59
 
116
60
  export function prepareStateDirForStart(
@@ -149,6 +93,7 @@ export function prepareStateDirForStart(
149
93
  "result.json",
150
94
  "stderr.log",
151
95
  "stdout.log",
96
+ "terminal-delivery-failure.json",
152
97
  "terminal-handled.json",
153
98
  ]) {
154
99
  rmSync(join(stateDir, file), { force: true });