@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
package/lib/async-runs.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Command-template async run lifecycle facade.
3
- * Owns launch, state observation, listing, message/control facade methods, and retention while runs-* subdomains own narrower run internals.
3
+ * Owns: launch, state observation, listing, message/control facade methods, and retention while runs-* subdomains own narrower run internals.
4
4
  */
5
5
 
6
6
  import { spawn } from "node:child_process";
7
+ import { randomUUID } from "node:crypto";
7
8
  import {
8
9
  closeSync,
9
10
  existsSync,
@@ -15,7 +16,7 @@ import {
15
16
  statSync,
16
17
  writeFileSync,
17
18
  } from "node:fs";
18
- import { basename, dirname, extname, join, relative, resolve } from "node:path";
19
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
19
20
  import { fileURLToPath } from "node:url";
20
21
 
21
22
  import type {
@@ -60,6 +61,7 @@ import {
60
61
  } from "./runs-process.ts";
61
62
  import * as RunsStart from "./runs-start.ts";
62
63
  import * as RunsIndex from "./runs-index.ts";
64
+ import * as RunsParentTeardown from "./runs-parent-teardown.ts";
63
65
  import {
64
66
  claimRunInboxMessageInStateDir,
65
67
  parseRunInboxLine,
@@ -75,7 +77,12 @@ import {
75
77
  deliverRunMessage,
76
78
  type SendRunMessageOptions,
77
79
  } from "./runs-messages.ts";
78
- import { buildRunStatus, tailFile, tailLines } from "./runs-status.ts";
80
+ import {
81
+ buildRunStatus,
82
+ tailFile,
83
+ tailLines,
84
+ type AsyncRunStatus,
85
+ } from "./runs-status.ts";
79
86
  import { readJsonFileResilient } from "./state-readers.ts";
80
87
 
81
88
  const RUNNER_IDENTITY_GRACE_MS = 5000;
@@ -87,11 +94,41 @@ export interface AsyncRunControlEndpoint {
87
94
  type: "fifo" | "mailbox" | "named-pipe";
88
95
  }
89
96
 
97
+ export function normalizeRunTransportContext(
98
+ value: unknown,
99
+ ): Record<string, string | number | boolean> | undefined {
100
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
101
+ const normalized: Record<string, string | number | boolean> = {};
102
+ for (const [key, item] of Object.entries(
103
+ value as Record<string, unknown>,
104
+ ).slice(0, 16)) {
105
+ const safeKey = key.trim().slice(0, 64);
106
+ if (!safeKey) continue;
107
+ if (typeof item === "string") {
108
+ normalized[safeKey] = item.trim().slice(0, 256);
109
+ continue;
110
+ }
111
+ if (typeof item === "number" && Number.isFinite(item)) {
112
+ normalized[safeKey] = item;
113
+ continue;
114
+ }
115
+ if (typeof item === "boolean") normalized[safeKey] = item;
116
+ }
117
+ return Object.keys(normalized).length ? normalized : undefined;
118
+ }
119
+
90
120
  export interface AsyncRunStartParams {
91
121
  async?: boolean;
92
122
  control?: AsyncRunControlEndpoint;
93
123
  file?: string;
94
124
  launch_source?: AsyncRunLaunchSource;
125
+ lifecycleHooks?: {
126
+ onLockContention?(): void;
127
+ };
128
+ launch_correlation?: {
129
+ correlation_id?: string;
130
+ tool_call_id?: string;
131
+ };
95
132
  name?: string;
96
133
  ownerId?: string;
97
134
  run_id?: string;
@@ -111,10 +148,12 @@ export interface AsyncRunStartParams {
111
148
  output?: string;
112
149
  artifacts?: Record<string, RunArtifactDeclaration>;
113
150
  mailbox?: RecipesReferences.TemplateRecipeMailbox;
151
+ notification_policy?: "normal" | "silent";
114
152
  retire_when?: "children_terminal";
115
153
  retry?: number | string;
116
154
  failure?: CommandTemplateFailureScope;
117
155
  recover?: CommandTemplateValue;
156
+ transport_context?: Record<string, unknown>;
118
157
  repeat?: number;
119
158
  values?: Record<string, unknown>;
120
159
  policy_values?: Record<string, unknown>;
@@ -122,24 +161,23 @@ export interface AsyncRunStartParams {
122
161
  cwd?: string;
123
162
  }
124
163
 
125
- export type AsyncRunStatus =
126
- | "running"
127
- | "done"
128
- | "failed"
129
- | "exited"
130
- | "cancelled"
131
- | "killed";
164
+ export type { AsyncRunStatus } from "./runs-status.ts";
132
165
 
133
166
  export interface AsyncRunMeta {
134
167
  argv: string[];
135
168
  createdAt: string;
136
169
  cwd: string;
137
170
  launch_source?: AsyncRunLaunchSource;
171
+ launch_correlation?: {
172
+ correlation_id?: string;
173
+ tool_call_id?: string;
174
+ };
138
175
  ownerId?: string;
139
176
  pid: number;
140
177
  recipe?: string;
141
178
  recipe_file?: string;
142
179
  run: string;
180
+ run_instance_id: string;
143
181
  state_dir: string;
144
182
  status: AsyncRunStatus;
145
183
  tool?: string;
@@ -149,9 +187,11 @@ export interface AsyncRunMeta {
149
187
  control?: AsyncRunControlEndpoint;
150
188
  mailbox?: RecipesReferences.TemplateRecipeMailbox;
151
189
  model_policy?: CurrentPolicyProvenance;
190
+ notification_policy?: "normal" | "silent";
152
191
  process_identity?: RunProcessIdentity;
153
192
  recipe_context_records?: RecipesReferences.TemplateRecipeContextRecord[];
154
193
  retire_when?: "children_terminal";
194
+ transport_context?: Record<string, unknown>;
155
195
  }
156
196
 
157
197
  const DEFAULT_STATE_ROOT = Paths.getRunStateRoot();
@@ -235,7 +275,8 @@ function resolveRecipeFile(file: string): string {
235
275
  function isMutableUsageRecipeFile(file: string): boolean {
236
276
  const userRoot = resolve(DEFAULT_RECIPE_ROOT);
237
277
  const resolved = resolve(file);
238
- return resolved.startsWith(`${userRoot}/`);
278
+ const relation = relative(userRoot, resolved);
279
+ return relation !== "" && !relation.startsWith("..") && !isAbsolute(relation);
239
280
  }
240
281
 
241
282
  function readRecipeFile(file: string): AsyncRunStartParams {
@@ -411,8 +452,11 @@ function assertCurrentPlaceholderReferencesResolved(
411
452
  }
412
453
  }
413
454
 
414
- function acquireStateStartLock(stateDir: string): () => void {
415
- return RunsStart.acquireStateStartLock(stateDir);
455
+ function acquireStateStartLock(
456
+ stateDir: string,
457
+ options: import("./file-state.ts").FileMutationLockOptions = {},
458
+ ): () => void {
459
+ return RunsStart.acquireStateStartLock(stateDir, options);
416
460
  }
417
461
 
418
462
  function prepareStateDirForStart(stateDir: string): void {
@@ -448,7 +492,9 @@ export function startRun(
448
492
  );
449
493
  assertNoActiveRunState(stateDir);
450
494
  mkdirSync(stateDir, { recursive: true });
451
- const releaseStartLock = acquireStateStartLock(stateDir);
495
+ const releaseStartLock = acquireStateStartLock(stateDir, {
496
+ onContention: startParams.lifecycleHooks?.onLockContention,
497
+ });
452
498
  try {
453
499
  claimRunStateDirectory(stateDir, run);
454
500
  assertNoActiveRunState(stateDir);
@@ -466,11 +512,17 @@ export function startRun(
466
512
  recipeFile && includeActorRecipeContext
467
513
  ? RecipesReferences.buildRecipeContextRecords(recipeFile)
468
514
  : undefined;
469
- if (recipeFile && isMutableUsageRecipeFile(recipeFile)) {
470
- RecipesUsage.recordRecipeLaunch(
515
+ if (
516
+ recipeFile &&
517
+ isMutableUsageRecipeFile(recipeFile) &&
518
+ !RecipesUsage.recordRecipeLaunch(
471
519
  recipeFile,
472
520
  new Date(),
473
521
  startParams.launch_source === "tool" ? "tool" : "spawn",
522
+ )
523
+ ) {
524
+ throw new Error(
525
+ `Recipe launch rejected because its source changed during activation: ${recipeFile}. Reload recipe tools and retry.`,
474
526
  );
475
527
  }
476
528
  const outFd = openSync(stdout, "a");
@@ -480,6 +532,9 @@ export function startRun(
480
532
  ...(startParams.defaults || {}),
481
533
  ...values,
482
534
  };
535
+ const transportContext = normalizeRunTransportContext(
536
+ startParams.transport_context,
537
+ );
483
538
  const artifacts = resolveArtifactPaths(startParams.artifacts, outputValues);
484
539
  const meta: AsyncRunMeta = {
485
540
  argv: [process.execPath, ...argv],
@@ -488,11 +543,14 @@ export function startRun(
488
543
  ...(startParams.launch_source
489
544
  ? { launch_source: startParams.launch_source }
490
545
  : {}),
546
+ ...(startParams.launch_correlation
547
+ ? { launch_correlation: startParams.launch_correlation } : {}),
491
548
  ...(startParams.ownerId ? { ownerId: startParams.ownerId } : {}),
492
549
  pid: 0,
493
550
  ...(recipe ? { recipe } : {}),
494
551
  ...(recipeFile ? { recipe_file: recipeFile } : {}),
495
552
  run,
553
+ run_instance_id: randomUUID(),
496
554
  state_dir: stateDir,
497
555
  status: "running",
498
556
  ...(startParams.tool ? { tool: startParams.tool } : {}),
@@ -502,12 +560,17 @@ export function startRun(
502
560
  ...(artifacts ? { artifacts } : {}),
503
561
  ...(startParams.control ? { control: startParams.control } : {}),
504
562
  ...(startParams.mailbox ? { mailbox: startParams.mailbox } : {}),
563
+ ...(startParams.notification_policy === "silent"
564
+ ? { notification_policy: "silent" as const }
565
+ : {}),
505
566
  ...(recipeContextRecords && recipeContextRecords.length > 0
506
567
  ? { recipe_context_records: recipeContextRecords }
507
568
  : {}),
508
569
  ...(startParams.retire_when === "children_terminal"
509
570
  ? { retire_when: "children_terminal" as const }
510
571
  : {}),
572
+ ...(transportContext
573
+ ? { transport_context: transportContext } : {}),
511
574
  };
512
575
  writeJsonAtomic(join(stateDir, "run.json"), meta);
513
576
  const child = spawn(process.execPath, argv, {
@@ -535,7 +598,7 @@ export function startRun(
535
598
  });
536
599
  writeFileSync(
537
600
  join(stateDir, "events.jsonl"),
538
- `${JSON.stringify({ event: "run.start", run, pid: meta.pid, ts: new Date().toISOString() })}\n`,
601
+ `${JSON.stringify({ event: "run.start", run, run_instance_id: meta.run_instance_id, pid: meta.pid, ts: new Date().toISOString() })}\n`,
539
602
  { flag: "a" },
540
603
  );
541
604
  child.unref();
@@ -552,12 +615,16 @@ export type {
552
615
  RunOutboxLevel,
553
616
  } from "./runs-outbox.ts";
554
617
 
555
- export function getRunStatus(runOrDir: string): Record<string, unknown> {
556
- const stateDir = resolve(
557
- runOrDir.includes("/")
618
+ function resolveRunStateDir(runOrDir: string): string {
619
+ return resolve(
620
+ /[\\/]/u.test(runOrDir)
558
621
  ? runOrDir
559
622
  : join(DEFAULT_STATE_ROOT, safeRunId(runOrDir)),
560
623
  );
624
+ }
625
+
626
+ export function getRunStatus(runOrDir: string): Record<string, unknown> {
627
+ const stateDir = resolveRunStateDir(runOrDir);
561
628
  const meta = readJson(join(stateDir, "run.json"));
562
629
  if (!meta) throw new Error(`Run not found: ${runOrDir}`);
563
630
  return buildRunStatus(
@@ -574,10 +641,8 @@ export type { RunStateIndexEntry } from "./runs-index.ts";
574
641
 
575
642
  export function listRunStateDirs(
576
643
  stateRoot = DEFAULT_STATE_ROOT,
577
- depth = 0,
578
- seen = new Set<string>(),
579
644
  ): string[] {
580
- return RunsIndex.listRunStateDirs(stateRoot, depth, seen);
645
+ return RunsIndex.listRunStateDirs(stateRoot);
581
646
  }
582
647
 
583
648
  export function rebuildRunStateIndex(
@@ -599,6 +664,103 @@ export function listRuns(
599
664
  return RunsIndex.listRuns(stateRoot, getRunStatus, readJson, statusFilter);
600
665
  }
601
666
 
667
+ export type {
668
+ ParentRunTeardownAttempt,
669
+ ParentRunTeardownDiscoveryFailure,
670
+ ParentRunTeardownResult,
671
+ } from "./runs-parent-teardown.ts";
672
+
673
+ export interface ParentRunsTeardownSummaryResult
674
+ extends RunsParentTeardown.ParentRunTeardownResult {
675
+ summaryPath?: string;
676
+ }
677
+
678
+ export function teardownRunsOwnedByParent(
679
+ ownerId: string | undefined,
680
+ stateRoot = DEFAULT_STATE_ROOT,
681
+ options: { trigger?: string } = {},
682
+ ): ParentRunsTeardownSummaryResult {
683
+ const result = RunsParentTeardown.teardownParentRuns(ownerId, {
684
+ getRunStatus,
685
+ killRun,
686
+ listRunStatuses: () => {
687
+ const discovered = RunsIndex.discoverRunStateDirs(
688
+ stateRoot,
689
+ Number.POSITIVE_INFINITY,
690
+ );
691
+ const failures: RunsParentTeardown.ParentRunTeardownDiscoveryFailure[] =
692
+ discovered.issues.map((issue) => ({
693
+ path: issue.path,
694
+ reason: issue.reason.replaceAll("_", " "),
695
+ }));
696
+ const statuses = discovered.stateDirs.flatMap((stateDir) => {
697
+ try {
698
+ return [getRunStatus(stateDir)];
699
+ } catch (error) {
700
+ failures.push({
701
+ path: stateDir,
702
+ reason: error instanceof Error ? error.message : String(error),
703
+ });
704
+ return [];
705
+ }
706
+ });
707
+ return { failures, statuses };
708
+ },
709
+ recordAttempt: (attempt) => {
710
+ const current = getRunStatus(attempt.stateDir);
711
+ if (
712
+ !attempt.runInstanceId ||
713
+ current.run_instance_id !== attempt.runInstanceId
714
+ ) {
715
+ throw new Error("run generation changed before teardown evidence");
716
+ }
717
+ writeFileSync(
718
+ join(attempt.stateDir, "events.jsonl"),
719
+ `${JSON.stringify({
720
+ event: "run.parent_teardown",
721
+ ownerId: attempt.ownerId,
722
+ outcome: attempt.outcome,
723
+ reason: attempt.reason,
724
+ run: attempt.run,
725
+ run_instance_id: attempt.runInstanceId,
726
+ type: "control.kill",
727
+ trigger: options.trigger ?? "parent_shutdown",
728
+ ts: new Date().toISOString(),
729
+ })}\n`,
730
+ { flag: "a" },
731
+ );
732
+ },
733
+ });
734
+ const summaryPath = join(
735
+ stateRoot,
736
+ "teardown",
737
+ `${Date.now()}-${randomUUID()}.json`,
738
+ );
739
+ try {
740
+ writeJsonAtomic(
741
+ summaryPath,
742
+ RunsParentTeardown.buildBoundedParentTeardownSummary(
743
+ result,
744
+ ownerId ?? "unknown",
745
+ options.trigger ?? "parent_shutdown",
746
+ new Date().toISOString(),
747
+ ),
748
+ );
749
+ return { ...result, summaryPath };
750
+ } catch (error) {
751
+ const failure = {
752
+ path: summaryPath,
753
+ reason: `summary: ${error instanceof Error ? error.message : String(error)}`,
754
+ };
755
+ return {
756
+ ...result,
757
+ discoveryFailed: result.discoveryFailed + 1,
758
+ discoveryFailures: [...result.discoveryFailures, failure],
759
+ failed: result.failed + 1,
760
+ };
761
+ }
762
+ }
763
+
602
764
  export function tailRun(runOrDir: string, lines = 40): string {
603
765
  const status = getRunStatus(runOrDir);
604
766
  const stateDir = String(status.state_dir);
@@ -785,11 +947,11 @@ function finalizeInterruptedReviewEvidence(
785
947
  return {
786
948
  attempt: index + 1,
787
949
  stdout: {
788
- path: relative(stateDir, stdoutFile),
950
+ path: relative(stateDir, stdoutFile).replaceAll("\\", "/"),
789
951
  bytes: existsSync(stdoutFile) ? statSync(stdoutFile).size : 0,
790
952
  },
791
953
  stderr: {
792
- path: relative(stateDir, stderrFile),
954
+ path: relative(stateDir, stderrFile).replaceAll("\\", "/"),
793
955
  bytes: existsSync(stderrFile) ? statSync(stderrFile).size : 0,
794
956
  },
795
957
  };
@@ -815,26 +977,58 @@ function finalizeInterruptedReviewEvidence(
815
977
  });
816
978
  }
817
979
 
980
+ export interface RunControlExpectation {
981
+ onLocked?(): void;
982
+ ownerId?: string;
983
+ runInstanceId?: string;
984
+ }
985
+
818
986
  function stopRun(
819
987
  runOrDir: string,
820
988
  signal: NodeJS.Signals,
821
989
  event: string,
990
+ expected: RunControlExpectation = {},
822
991
  ): Record<string, unknown> {
823
- const status = getRunStatus(runOrDir);
824
- const pid = Number(status.pid || 0);
825
- const stateDir = String(status.state_dir);
826
- if (status.status !== "running" && status.status !== "exited") {
827
- return { stopped: false, reason: "not running", status };
828
- }
829
- const identity = verifyRunProcessIdentity(
830
- pid,
831
- status.process_identity as RunProcessIdentity | undefined,
832
- );
833
- if (status.status === "exited") {
992
+ const stateDir = resolveRunStateDir(runOrDir);
993
+ const releaseControlLock = RunsStart.acquireStateStartLock(stateDir);
994
+ try {
995
+ expected.onLocked?.();
996
+ const status = getRunStatus(stateDir);
834
997
  if (
835
- identity.status === "owner_mismatch" ||
836
- identity.status === "unsupported_proof"
998
+ expected.ownerId !== undefined &&
999
+ status.ownerId !== expected.ownerId
1000
+ ) {
1001
+ return { stopped: false, reason: "ownership changed", status };
1002
+ }
1003
+ if (
1004
+ expected.runInstanceId !== undefined &&
1005
+ status.run_instance_id !== expected.runInstanceId
837
1006
  ) {
1007
+ return { stopped: false, reason: "run generation changed", status };
1008
+ }
1009
+ const pid = Number(status.pid || 0);
1010
+ if (status.status !== "running" && status.status !== "exited") {
1011
+ return { stopped: false, reason: "not running", status };
1012
+ }
1013
+ const identity = verifyRunProcessIdentity(
1014
+ pid,
1015
+ status.process_identity as RunProcessIdentity | undefined,
1016
+ );
1017
+ if (status.status === "exited") {
1018
+ if (
1019
+ identity.status === "owner_mismatch" ||
1020
+ identity.status === "unsupported_proof"
1021
+ ) {
1022
+ return {
1023
+ stopped: false,
1024
+ reason: identity.status.replaceAll("_", " "),
1025
+ process_identity_status: identity.status,
1026
+ status,
1027
+ };
1028
+ }
1029
+ return { stopped: false, reason: "not running", status };
1030
+ }
1031
+ if (!identity.valid) {
838
1032
  return {
839
1033
  stopped: false,
840
1034
  reason: identity.status.replaceAll("_", " "),
@@ -842,32 +1036,29 @@ function stopRun(
842
1036
  status,
843
1037
  };
844
1038
  }
845
- return { stopped: false, reason: "not running", status };
846
- }
847
- if (!identity.valid) {
848
- return {
849
- stopped: false,
850
- reason: identity.status.replaceAll("_", " "),
851
- process_identity_status: identity.status,
852
- status,
853
- };
854
- }
855
- const signalResult = signalOwnedRunProcess(pid, signal);
856
- writeFileSync(
857
- join(stateDir, "events.jsonl"),
858
- `${JSON.stringify({ event, pid, signal, ...signalResult, ts: new Date().toISOString() })}\n`,
859
- { flag: "a" },
860
- );
861
- markTerminalHandled(stateDir, { event, signal });
862
- if (event === "run.kill") {
863
- finalizeInterruptedReviewEvidence(stateDir, "killed", signal);
864
- markTerminalProgress(stateDir, "killed");
865
- }
866
- if (event === "run.cancel") {
867
- finalizeInterruptedReviewEvidence(stateDir, "cancelled", signal);
868
- markTerminalProgress(stateDir, "cancelled");
1039
+ const signalResult = signalOwnedRunProcess(
1040
+ pid,
1041
+ signal,
1042
+ status.process_identity as RunProcessIdentity,
1043
+ );
1044
+ writeFileSync(
1045
+ join(stateDir, "events.jsonl"),
1046
+ `${JSON.stringify({ event, pid, signal, ...signalResult, ts: new Date().toISOString() })}\n`,
1047
+ { flag: "a" },
1048
+ );
1049
+ markTerminalHandled(stateDir, { event, signal });
1050
+ if (event === "run.kill") {
1051
+ finalizeInterruptedReviewEvidence(stateDir, "killed", signal);
1052
+ markTerminalProgress(stateDir, "killed");
1053
+ }
1054
+ if (event === "run.cancel") {
1055
+ finalizeInterruptedReviewEvidence(stateDir, "cancelled", signal);
1056
+ markTerminalProgress(stateDir, "cancelled");
1057
+ }
1058
+ return { stopped: true, pid, signal, ...signalResult, state_dir: stateDir };
1059
+ } finally {
1060
+ releaseControlLock();
869
1061
  }
870
- return { stopped: true, pid, signal, ...signalResult, state_dir: stateDir };
871
1062
  }
872
1063
 
873
1064
  export function markRunTerminalNotificationHandled(
@@ -880,8 +1071,30 @@ export function markRunTerminalNotificationHandled(
880
1071
  });
881
1072
  }
882
1073
 
883
- export function cancelRun(runOrDir: string): Record<string, unknown> {
884
- const result = stopRun(runOrDir, "SIGTERM", "run.cancel");
1074
+ export function recordRunTerminalDeliveryFailure(
1075
+ stateDir: string,
1076
+ status: string,
1077
+ error: unknown,
1078
+ ): void {
1079
+ const path = join(stateDir, "terminal-delivery-failure.json");
1080
+ const previous = readJson(path);
1081
+ const message = (error instanceof Error ? error.message : String(error))
1082
+ .replaceAll(/\s+/g, " ")
1083
+ .trim()
1084
+ .slice(0, 500);
1085
+ writeJsonAtomic(path, {
1086
+ attempts: Math.max(0, Number(previous?.attempts ?? 0)) + 1,
1087
+ error: message || "unknown delivery failure",
1088
+ status,
1089
+ ts: new Date().toISOString(),
1090
+ });
1091
+ }
1092
+
1093
+ export function cancelRun(
1094
+ runOrDir: string,
1095
+ expected: RunControlExpectation = {},
1096
+ ): Record<string, unknown> {
1097
+ const result = stopRun(runOrDir, "SIGTERM", "run.cancel", expected);
885
1098
  return Object.hasOwn(result, "stopped")
886
1099
  ? { cancelled: result.stopped, ...result }
887
1100
  : result;
@@ -906,8 +1119,11 @@ export function pruneRun(
906
1119
  return pruneTerminalRun(assertTerminalRun(runOrDir), options);
907
1120
  }
908
1121
 
909
- export function killRun(runOrDir: string): Record<string, unknown> {
910
- const result = stopRun(runOrDir, "SIGKILL", "run.kill");
1122
+ export function killRun(
1123
+ runOrDir: string,
1124
+ expected: RunControlExpectation = {},
1125
+ ): Record<string, unknown> {
1126
+ const result = stopRun(runOrDir, "SIGKILL", "run.kill", expected);
911
1127
  return Object.hasOwn(result, "stopped")
912
1128
  ? { killed: result.stopped, ...result }
913
1129
  : result;
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Automatic recipe-review runtime composition.
3
+ * Zones: session-bound review schedulers, silent reviewer launch adapters, review controls
4
+ * Owns automatic-review lifecycle wiring without owning review decisions or filesystem transactions.
5
+ */
6
+
7
+ import { join } from "node:path";
8
+
9
+ import * as AsyncRuns from "./async-runs.ts";
10
+ import * as DraftSleep from "./draft-sleep.ts";
11
+ import * as ModelContext from "./model-context.ts";
12
+ import * as Paths from "./paths.ts";
13
+ import type * as Pi from "./pi.ts";
14
+ import * as ReviewControl from "./review-control.ts";
15
+ import * as ToolReviewScheduler from "./tool-review-scheduler.ts";
16
+
17
+ export interface AutomaticReviewRuntime {
18
+ close(): void;
19
+ handleMessage(type: string, body: unknown): Record<string, unknown>;
20
+ schedule(): void;
21
+ start(ctx: Pi.ExtensionContext): void;
22
+ }
23
+
24
+ export interface AutomaticReviewRuntimeDeps {
25
+ getActiveContext(): Pi.ExtensionContext | undefined;
26
+ getRunOwnerId(ctx: Pi.ExtensionContext): string;
27
+ getThinkingLevel(): unknown;
28
+ }
29
+
30
+ export function createAutomaticReviewRuntime(
31
+ deps: AutomaticReviewRuntimeDeps,
32
+ ): AutomaticReviewRuntime {
33
+ let draftScheduler: DraftSleep.DraftSleepScheduler | undefined;
34
+ let toolScheduler: ToolReviewScheduler.ToolReviewScheduler | undefined;
35
+
36
+ const close = (): void => {
37
+ draftScheduler?.close();
38
+ draftScheduler = undefined;
39
+ toolScheduler?.close();
40
+ toolScheduler = undefined;
41
+ };
42
+ const hasActiveActors = (): boolean =>
43
+ AsyncRuns.listRuns(Paths.EXTENSION_RUNTIME_PATHS.runStateRoot, "running").length > 0;
44
+ const policyValues = (ctx: Pi.ExtensionContext): Record<string, unknown> =>
45
+ ModelContext.withCurrentModelValues(
46
+ {},
47
+ {
48
+ ...(ctx as ModelContext.CurrentModelContext),
49
+ getThinkingLevel: deps.getThinkingLevel,
50
+ },
51
+ );
52
+
53
+ return {
54
+ close,
55
+ handleMessage(type, body) {
56
+ if (type !== "review.retry" && type !== "review.reset") {
57
+ throw new Error("tool:pi-actors accepts review.retry or review.reset messages.");
58
+ }
59
+ if (type === "review.retry" && !Paths.isAutomaticRecipeReviewEnabled()) {
60
+ throw new Error(
61
+ "Automatic recipe review is disabled by PI_ACTORS_AUTOMATIC_REVIEW.",
62
+ );
63
+ }
64
+ return ReviewControl.controlAutomaticReview(
65
+ type,
66
+ ReviewControl.parseAutomaticReviewScope(body),
67
+ {
68
+ scheduleDraft: () => draftScheduler?.schedule(),
69
+ scheduleTool: () => toolScheduler?.schedule(),
70
+ },
71
+ );
72
+ },
73
+ schedule() {
74
+ draftScheduler?.schedule();
75
+ toolScheduler?.schedule();
76
+ },
77
+ start(ctx) {
78
+ close();
79
+ if (!Paths.isAutomaticRecipeReviewEnabled()) return;
80
+ ToolReviewScheduler.applyApprovedToolReviewAtSessionBoundary({
81
+ recipeRoot: Paths.getRecipeRoot(),
82
+ });
83
+ draftScheduler = DraftSleep.createDraftSleepScheduler({
84
+ hasActiveActors,
85
+ launch: (batch) => {
86
+ if (deps.getActiveContext() !== ctx) {
87
+ throw new Error("Draft review session changed before launch.");
88
+ }
89
+ return AsyncRuns.startRun(
90
+ {
91
+ file: join(Paths.getPackagedRecipeRoot(), "draft-review.json"),
92
+ launch_source: "tool",
93
+ notification_policy: "silent",
94
+ ownerId: deps.getRunOwnerId(ctx),
95
+ policy_values: policyValues(ctx),
96
+ run_id: DraftSleep.draftSleepRunId(batch.batchId),
97
+ values: { input_path: batch.reviewerInputPath },
98
+ },
99
+ ctx.cwd,
100
+ );
101
+ },
102
+ process: (state) =>
103
+ DraftSleep.processDraftSleepReview(state, {
104
+ getRunStatus: AsyncRuns.getRunStatus,
105
+ recipeRoot: Paths.getRecipeRoot(),
106
+ }),
107
+ });
108
+ toolScheduler = ToolReviewScheduler.createToolReviewScheduler({
109
+ hasActiveActors,
110
+ launch: (batch) => {
111
+ if (deps.getActiveContext() !== ctx) {
112
+ throw new Error("Tool review session changed before launch.");
113
+ }
114
+ return AsyncRuns.startRun(
115
+ {
116
+ file: join(Paths.getPackagedRecipeRoot(), "tool-review.json"),
117
+ launch_source: "tool",
118
+ notification_policy: "silent",
119
+ ownerId: deps.getRunOwnerId(ctx),
120
+ policy_values: policyValues(ctx),
121
+ run_id: ToolReviewScheduler.toolReviewRunId(batch.reviewId),
122
+ values: { input_path: batch.reviewerInputPath },
123
+ },
124
+ ctx.cwd,
125
+ );
126
+ },
127
+ process: (state) =>
128
+ ToolReviewScheduler.processToolReviewResult(state, {
129
+ getRunStatus: AsyncRuns.getRunStatus,
130
+ recipeRoot: Paths.getRecipeRoot(),
131
+ }),
132
+ });
133
+ },
134
+ };
135
+ }