@llblab/pi-actors 0.41.0 → 0.42.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 (150) hide show
  1. package/AGENTS.md +13 -8
  2. package/BACKLOG.md +2 -81
  3. package/CHANGELOG.md +22 -0
  4. package/README.md +22 -4
  5. package/dist/index.js +29 -82
  6. package/dist/lib/async-runs.d.ts +25 -5
  7. package/dist/lib/async-runs.js +136 -47
  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/draft-consolidation-transaction.d.ts +65 -0
  11. package/dist/lib/draft-consolidation-transaction.js +610 -0
  12. package/dist/lib/draft-consolidation.d.ts +35 -0
  13. package/dist/lib/draft-consolidation.js +126 -0
  14. package/dist/lib/draft-review.d.ts +56 -0
  15. package/dist/lib/draft-review.js +254 -0
  16. package/dist/lib/draft-sleep.d.ts +65 -0
  17. package/dist/lib/draft-sleep.js +468 -0
  18. package/dist/lib/file-state.d.ts +8 -1
  19. package/dist/lib/file-state.js +115 -18
  20. package/dist/lib/inspector-actions.d.ts +16 -0
  21. package/dist/lib/inspector-actions.js +57 -0
  22. package/dist/lib/inspector-command.d.ts +7 -0
  23. package/dist/lib/inspector-command.js +37 -0
  24. package/dist/lib/inspector-overlay.d.ts +29 -1
  25. package/dist/lib/inspector-overlay.js +537 -90
  26. package/dist/lib/inspector.d.ts +9 -0
  27. package/dist/lib/inspector.js +52 -1
  28. package/dist/lib/observability.d.ts +39 -3
  29. package/dist/lib/observability.js +162 -34
  30. package/dist/lib/paths.d.ts +7 -0
  31. package/dist/lib/paths.js +28 -0
  32. package/dist/lib/recipes-discovery.js +32 -24
  33. package/dist/lib/recipes-usage.d.ts +18 -6
  34. package/dist/lib/recipes-usage.js +445 -34
  35. package/dist/lib/review-control.d.ts +14 -0
  36. package/dist/lib/review-control.js +111 -0
  37. package/dist/lib/review-diagnostics.d.ts +11 -0
  38. package/dist/lib/review-diagnostics.js +148 -0
  39. package/dist/lib/review-projection.d.ts +14 -0
  40. package/dist/lib/review-projection.js +170 -0
  41. package/dist/lib/run-ui-runtime.d.ts +18 -0
  42. package/dist/lib/run-ui-runtime.js +123 -0
  43. package/dist/lib/runs-artifacts.d.ts +1 -1
  44. package/dist/lib/runs-artifacts.js +1 -1
  45. package/dist/lib/runs-control.d.ts +8 -2
  46. package/dist/lib/runs-control.js +23 -6
  47. package/dist/lib/runs-identity.d.ts +1 -1
  48. package/dist/lib/runs-identity.js +1 -1
  49. package/dist/lib/runs-index.d.ts +11 -2
  50. package/dist/lib/runs-index.js +46 -23
  51. package/dist/lib/runs-mailbox.d.ts +1 -1
  52. package/dist/lib/runs-mailbox.js +1 -1
  53. package/dist/lib/runs-messages.d.ts +1 -1
  54. package/dist/lib/runs-messages.js +1 -1
  55. package/dist/lib/runs-outbox.d.ts +1 -1
  56. package/dist/lib/runs-outbox.js +1 -1
  57. package/dist/lib/runs-ownership.d.ts +1 -1
  58. package/dist/lib/runs-ownership.js +1 -1
  59. package/dist/lib/runs-parent-teardown.d.ts +51 -0
  60. package/dist/lib/runs-parent-teardown.js +172 -0
  61. package/dist/lib/runs-process.d.ts +1 -1
  62. package/dist/lib/runs-process.js +1 -1
  63. package/dist/lib/runs-retention.d.ts +1 -1
  64. package/dist/lib/runs-retention.js +1 -1
  65. package/dist/lib/runs-start.d.ts +5 -3
  66. package/dist/lib/runs-start.js +6 -48
  67. package/dist/lib/runs-status.d.ts +5 -3
  68. package/dist/lib/runs-status.js +4 -6
  69. package/dist/lib/runtime.d.ts +7 -1
  70. package/dist/lib/runtime.js +32 -18
  71. package/dist/lib/tool-review-lineage-transaction.d.ts +27 -0
  72. package/dist/lib/tool-review-lineage-transaction.js +597 -0
  73. package/dist/lib/tool-review-lineage.d.ts +24 -0
  74. package/dist/lib/tool-review-lineage.js +98 -0
  75. package/dist/lib/tool-review-scheduler.d.ts +80 -0
  76. package/dist/lib/tool-review-scheduler.js +494 -0
  77. package/dist/lib/tool-review-transaction.d.ts +50 -0
  78. package/dist/lib/tool-review-transaction.js +362 -0
  79. package/dist/lib/tool-review.d.ts +56 -0
  80. package/dist/lib/tool-review.js +197 -0
  81. package/dist/lib/tools-inspect.js +26 -3
  82. package/dist/lib/tools-local.js +4 -2
  83. package/dist/lib/tools-message.d.ts +1 -0
  84. package/dist/lib/tools-message.js +29 -17
  85. package/dist/lib/tools-response.d.ts +1 -1
  86. package/dist/lib/tools-response.js +5 -7
  87. package/dist/lib/tools-spawn.js +4 -1
  88. package/dist/lib/tools.d.ts +1 -0
  89. package/dist/lib/tools.js +1 -0
  90. package/dist/recipes/draft-review.json +24 -0
  91. package/dist/recipes/tool-review.json +24 -0
  92. package/dist/scripts/release-gates.mjs +165 -0
  93. package/dist/skills/actors/SKILL.md +11 -14
  94. package/dist/skills/swarm/SKILL.md +1 -1
  95. package/docs/actor-inspector.md +32 -18
  96. package/docs/async-runs.md +10 -2
  97. package/docs/recipe-library.md +7 -3
  98. package/docs/template-recipes.md +4 -11
  99. package/docs/tool-registry.md +11 -4
  100. package/index.ts +29 -103
  101. package/lib/async-runs.ts +218 -62
  102. package/lib/automatic-review-runtime.ts +135 -0
  103. package/lib/draft-consolidation-transaction.ts +821 -0
  104. package/lib/draft-consolidation.ts +181 -0
  105. package/lib/draft-review.ts +325 -0
  106. package/lib/draft-sleep.ts +576 -0
  107. package/lib/file-state.ts +143 -19
  108. package/lib/inspector-actions.ts +79 -0
  109. package/lib/inspector-command.ts +54 -0
  110. package/lib/inspector-overlay.ts +675 -105
  111. package/lib/inspector.ts +78 -3
  112. package/lib/observability.ts +219 -40
  113. package/lib/paths.ts +43 -0
  114. package/lib/recipes-discovery.ts +34 -26
  115. package/lib/recipes-usage.ts +569 -40
  116. package/lib/review-control.ts +137 -0
  117. package/lib/review-diagnostics.ts +164 -0
  118. package/lib/review-projection.ts +200 -0
  119. package/lib/run-ui-runtime.ts +153 -0
  120. package/lib/runs-artifacts.ts +1 -1
  121. package/lib/runs-control.ts +49 -5
  122. package/lib/runs-identity.ts +1 -1
  123. package/lib/runs-index.ts +57 -21
  124. package/lib/runs-mailbox.ts +1 -1
  125. package/lib/runs-messages.ts +1 -1
  126. package/lib/runs-outbox.ts +1 -1
  127. package/lib/runs-ownership.ts +1 -1
  128. package/lib/runs-parent-teardown.ts +257 -0
  129. package/lib/runs-process.ts +1 -1
  130. package/lib/runs-retention.ts +1 -1
  131. package/lib/runs-start.ts +12 -68
  132. package/lib/runs-status.ts +12 -8
  133. package/lib/runtime.ts +34 -17
  134. package/lib/tool-review-lineage-transaction.ts +881 -0
  135. package/lib/tool-review-lineage.ts +145 -0
  136. package/lib/tool-review-scheduler.ts +635 -0
  137. package/lib/tool-review-transaction.ts +563 -0
  138. package/lib/tool-review.ts +270 -0
  139. package/lib/tools-inspect.ts +33 -3
  140. package/lib/tools-local.ts +8 -2
  141. package/lib/tools-message.ts +45 -30
  142. package/lib/tools-response.ts +5 -6
  143. package/lib/tools-spawn.ts +8 -1
  144. package/lib/tools.ts +5 -0
  145. package/package.json +3 -2
  146. package/recipes/draft-review.json +24 -0
  147. package/recipes/tool-review.json +24 -0
  148. package/scripts/release-gates.mjs +165 -0
  149. package/skills/actors/SKILL.md +11 -14
  150. package/skills/swarm/SKILL.md +1 -1
@@ -0,0 +1,576 @@
1
+ /**
2
+ * Automatic draft-sleep scheduling.
3
+ * Zones: twelve-draft trigger, immutable batch capture, silent post-turn launch
4
+ * Owns non-blocking review admission and persisted launch evidence, not reviewer decisions or mutation.
5
+ */
6
+
7
+ import { createHash, randomUUID } from "node:crypto";
8
+ import { existsSync, readFileSync, rmSync } from "node:fs";
9
+ import { basename, dirname, join } from "node:path";
10
+
11
+ import type { DraftConsolidationPlan } from "./draft-consolidation.ts";
12
+ import * as DraftConsolidationTransaction from "./draft-consolidation-transaction.ts";
13
+ import type {
14
+ DraftReviewActiveTool,
15
+ DraftReviewDraft,
16
+ DraftReviewInput,
17
+ } from "./draft-review.ts";
18
+ import {
19
+ parseDraftReviewResult,
20
+ validateDraftReviewInput,
21
+ validateDraftReviewResult,
22
+ } from "./draft-review.ts";
23
+ import { withFileMutationLock, writeJsonAtomic } from "./file-state.ts";
24
+ import * as Paths from "./paths.ts";
25
+ import * as RecipesDiscovery from "./recipes-discovery.ts";
26
+ import * as RecipesReferences from "./recipes-references.ts";
27
+ import * as RecipesUsage from "./recipes-usage.ts";
28
+ import * as ReviewProjection from "./review-projection.ts";
29
+
30
+ export const DRAFT_SLEEP_THRESHOLD = 12;
31
+ const DEFAULT_DELAY_MS = 25;
32
+
33
+ export interface DraftSleepBatch {
34
+ batchId: string;
35
+ inputPath: string;
36
+ reviewerInputPath: string;
37
+ sourcePaths: string[];
38
+ }
39
+
40
+ export interface DraftSleepState {
41
+ attempts: number;
42
+ batchId: string;
43
+ inputPath: string;
44
+ evidencePath?: string;
45
+ failedStage?: string;
46
+ lastError?: string;
47
+ nextAction?: string;
48
+ phase: "captured" | "completed" | "failed" | "launched" | "processing_failed";
49
+ processingAttempts?: number;
50
+ reviewerInputPath: string;
51
+ runId?: string;
52
+ sourcePaths: string[];
53
+ updatedAt: string;
54
+ }
55
+
56
+ export interface DraftSleepProcessResult {
57
+ cleanup?(): void;
58
+ error?: string;
59
+ evidencePath?: string;
60
+ outcome: "completed" | "pending" | "processing_failed" | "review_failed";
61
+ stage?: string;
62
+ }
63
+
64
+ export interface DraftSleepSchedulerDeps {
65
+ activeTools?(): DraftReviewActiveTool[];
66
+ batchRoot?(batchId: string): string;
67
+ createBatchId?(): string;
68
+ delayMs?: number;
69
+ draftRoot?: string;
70
+ hasActiveActors(): boolean;
71
+ launch(batch: DraftSleepBatch): { run: string };
72
+ now?(): Date;
73
+ process?(state: DraftSleepState): DraftSleepProcessResult;
74
+ recipeRoot?: string;
75
+ statePath?: string;
76
+ }
77
+
78
+ export interface DraftSleepScheduler {
79
+ close(): void;
80
+ schedule(): void;
81
+ }
82
+
83
+ function sha256(path: string): string {
84
+ return createHash("sha256").update(readFileSync(path)).digest("hex");
85
+ }
86
+
87
+ function readState(path: string): DraftSleepState | undefined {
88
+ try {
89
+ const value = JSON.parse(readFileSync(path, "utf8")) as DraftSleepState;
90
+ return value && typeof value === "object" ? value : undefined;
91
+ } catch {
92
+ return undefined;
93
+ }
94
+ }
95
+
96
+ function defaultActiveTools(recipeRoot: string): DraftReviewActiveTool[] {
97
+ const discovered = RecipesDiscovery.discoverRecipeSources([
98
+ { root: recipeRoot, defaultTool: true, mutableUsage: true },
99
+ { root: Paths.getPackagedRecipeRoot() },
100
+ ]);
101
+ return [...discovered.active.values()]
102
+ .filter((entry) => existsSync(entry.path))
103
+ .map((entry) => ({
104
+ name: entry.id,
105
+ path: entry.path,
106
+ sha256: sha256(entry.path),
107
+ }))
108
+ .sort((left, right) => left.name.localeCompare(right.name));
109
+ }
110
+
111
+ function draftFromRecord(
112
+ record: Record<string, unknown>,
113
+ recipeRoot: string,
114
+ now: Date,
115
+ ): DraftReviewDraft | undefined {
116
+ if (
117
+ typeof record.path !== "string" ||
118
+ typeof record.sha256 !== "string" ||
119
+ typeof record.valid !== "boolean"
120
+ ) {
121
+ return undefined;
122
+ }
123
+ RecipesUsage.ensureRecipeLineage(record.path, now, recipeRoot);
124
+ const riskLabels = Array.isArray(record.risk_labels)
125
+ ? record.risk_labels.filter((label): label is string => typeof label === "string")
126
+ : [];
127
+ const secretTouching = riskLabels.includes("risk.secret_touching");
128
+ const recipe = secretTouching || !record.valid
129
+ ? undefined
130
+ : RecipesReferences.readRawRecipeConfig(record.path);
131
+ const usage = RecipesUsage.readRecipeUsage(record.path, recipeRoot);
132
+ if (
133
+ typeof usage?.demoted_fingerprint === "string" &&
134
+ usage.demoted_fingerprint === usage.fingerprint
135
+ ) {
136
+ return undefined;
137
+ }
138
+ return {
139
+ ...(record.diagnostics !== undefined
140
+ ? { diagnostics: record.diagnostics }
141
+ : {}),
142
+ path: record.path,
143
+ ...(recipe ? { recipe } : {}),
144
+ riskLabels,
145
+ sha256: record.sha256,
146
+ ...(usage ? { usage } : {}),
147
+ valid: record.valid && !secretTouching,
148
+ };
149
+ }
150
+
151
+ type DraftSleepCaptureOptions = Pick<
152
+ DraftSleepSchedulerDeps,
153
+ | "activeTools"
154
+ | "batchRoot"
155
+ | "createBatchId"
156
+ | "draftRoot"
157
+ | "now"
158
+ | "recipeRoot"
159
+ | "statePath"
160
+ >;
161
+
162
+ function captureDraftSleepBatchLocked(
163
+ options: DraftSleepCaptureOptions,
164
+ draftRoot: string,
165
+ recipeRoot: string,
166
+ statePath: string,
167
+ ): DraftSleepBatch | undefined {
168
+ const state = readState(statePath);
169
+ if (
170
+ state?.phase === "captured" ||
171
+ state?.phase === "launched" ||
172
+ state?.phase === "processing_failed"
173
+ ) return undefined;
174
+ return withFileMutationLock(draftRoot, () => {
175
+ const records = RecipesDiscovery.listDraftRecipes(draftRoot);
176
+ const capturedAt = (options.now ?? (() => new Date()))();
177
+ const candidates = records.flatMap((record) => {
178
+ const draft = draftFromRecord(record, recipeRoot, capturedAt);
179
+ return draft ? [draft] : [];
180
+ });
181
+ if (candidates.length < DRAFT_SLEEP_THRESHOLD) return undefined;
182
+ const batchId = (options.createBatchId ?? randomUUID)();
183
+ const batchDir = (options.batchRoot ?? Paths.getDraftSleepBatchDir)(batchId);
184
+ const inputPath = join(batchDir, "input.json");
185
+ const drafts = candidates.slice(0, DRAFT_SLEEP_THRESHOLD);
186
+ const input: DraftReviewInput = {
187
+ activeTools: options.activeTools?.() ?? defaultActiveTools(recipeRoot),
188
+ batchId,
189
+ createdAt: capturedAt.toISOString(),
190
+ drafts,
191
+ };
192
+ const validation = validateDraftReviewInput(input);
193
+ if (!validation.ok) {
194
+ throw new Error(`Draft sleep input rejected: ${validation.errors.join("; ")}`);
195
+ }
196
+ writeJsonAtomic(inputPath, input);
197
+ const reviewerInputPath = join(batchDir, "review-input.json");
198
+ writeJsonAtomic(reviewerInputPath, ReviewProjection.projectDraftReviewInput(input));
199
+ const sourcePaths = drafts.map((draft) => draft.path);
200
+ writeJsonAtomic(statePath, {
201
+ attempts: 0,
202
+ batchId,
203
+ inputPath,
204
+ phase: "captured",
205
+ reviewerInputPath,
206
+ sourcePaths,
207
+ updatedAt: input.createdAt,
208
+ } satisfies DraftSleepState);
209
+ return { batchId, inputPath, reviewerInputPath, sourcePaths };
210
+ });
211
+ }
212
+
213
+ export function captureDraftSleepBatch(
214
+ options: DraftSleepCaptureOptions = {},
215
+ ): DraftSleepBatch | undefined {
216
+ const draftRoot = options.draftRoot ?? Paths.getRecipeDraftRoot();
217
+ const recipeRoot = options.recipeRoot ?? Paths.getRecipeRoot();
218
+ const statePath = options.statePath ?? Paths.getDraftSleepStatePath();
219
+ return withFileMutationLock(statePath, () =>
220
+ captureDraftSleepBatchLocked(options, draftRoot, recipeRoot, statePath),
221
+ );
222
+ }
223
+
224
+ export interface ProcessDraftSleepReviewDeps {
225
+ getRunStatus(runId: string): Record<string, unknown>;
226
+ now?(): Date;
227
+ recipeRoot?: string;
228
+ }
229
+
230
+ function completeDraftSleepTransaction(
231
+ transaction: DraftConsolidationTransaction.DraftConsolidationTransactionResult,
232
+ state: DraftSleepState,
233
+ deps: ProcessDraftSleepReviewDeps,
234
+ cycleDir: string,
235
+ recipeRoot: string,
236
+ ): DraftSleepProcessResult {
237
+ try {
238
+ if (transaction.phase !== "committed") {
239
+ return {
240
+ error: `draft transaction remained ${transaction.phase}`,
241
+ outcome: "processing_failed",
242
+ stage: "transaction",
243
+ };
244
+ }
245
+ const committedDecisions = transaction.plan.drafts;
246
+ for (const decision of committedDecisions) {
247
+ if (decision.action === "promote") {
248
+ const targetPath = join(recipeRoot, `${decision.target}.json`);
249
+ if (!RecipesUsage.moveRecipeUsage(decision.draft, targetPath, recipeRoot)) {
250
+ throw new Error(`Could not transfer promoted recipe lineage: ${decision.draft}`);
251
+ }
252
+ } else if (!RecipesUsage.retireRecipeUsage(decision.draft, recipeRoot)) {
253
+ throw new Error(`Could not retire discarded recipe lineage: ${decision.draft}`);
254
+ }
255
+ }
256
+ const evidencePath = join(cycleDir, "sleep-evidence.json");
257
+ writeJsonAtomic(evidencePath, {
258
+ batchId: state.batchId,
259
+ decisions: committedDecisions,
260
+ garbageCollection: {
261
+ reviewedSources: committedDecisions.length,
262
+ status: "pending",
263
+ },
264
+ transactionEvidencePath: transaction.evidencePath,
265
+ ts: (deps.now ?? (() => new Date()))().toISOString(),
266
+ });
267
+ return {
268
+ cleanup: () => {
269
+ rmSync(join(cycleDir, "quarantine"), { recursive: true, force: true });
270
+ rmSync(join(cycleDir, "backups"), { recursive: true, force: true });
271
+ writeJsonAtomic(evidencePath, {
272
+ batchId: state.batchId,
273
+ decisions: committedDecisions,
274
+ garbageCollection: {
275
+ reviewedSources: committedDecisions.length,
276
+ status: "completed",
277
+ },
278
+ transactionEvidencePath: transaction.evidencePath,
279
+ ts: (deps.now ?? (() => new Date()))().toISOString(),
280
+ });
281
+ },
282
+ evidencePath,
283
+ outcome: "completed",
284
+ };
285
+ } catch (error) {
286
+ return {
287
+ error: error instanceof Error ? error.message : String(error),
288
+ outcome: "processing_failed",
289
+ stage: "transaction_or_lineage",
290
+ };
291
+ }
292
+ }
293
+
294
+ export function processDraftSleepReview(
295
+ state: DraftSleepState,
296
+ deps: ProcessDraftSleepReviewDeps,
297
+ ): DraftSleepProcessResult {
298
+ const recipeRoot = deps.recipeRoot ?? Paths.getRecipeRoot();
299
+ const cycleDir = dirname(state.inputPath);
300
+ if (existsSync(join(cycleDir, "journal.json"))) {
301
+ const firstSource = state.sourcePaths[0];
302
+ if (!firstSource) {
303
+ return {
304
+ error: "captured draft source is unavailable for transaction recovery",
305
+ outcome: "processing_failed",
306
+ stage: "transaction",
307
+ };
308
+ }
309
+ try {
310
+ const transaction = DraftConsolidationTransaction.recoverDraftConsolidationCycle({
311
+ cycleDir,
312
+ draftRoot: dirname(firstSource),
313
+ recipeRoot,
314
+ });
315
+ return completeDraftSleepTransaction(transaction, state, deps, cycleDir, recipeRoot);
316
+ } catch (error) {
317
+ return {
318
+ error: error instanceof Error ? error.message : String(error),
319
+ outcome: "processing_failed",
320
+ stage: "transaction_or_lineage",
321
+ };
322
+ }
323
+ }
324
+ if (!state.runId) {
325
+ return { error: "review run id is unavailable", outcome: "review_failed", stage: "review_state" };
326
+ }
327
+ let status: Record<string, unknown>;
328
+ try {
329
+ status = deps.getRunStatus(state.runId);
330
+ } catch (error) {
331
+ return {
332
+ error: error instanceof Error ? error.message : String(error),
333
+ outcome: "review_failed",
334
+ stage: "review_state",
335
+ };
336
+ }
337
+ if (status.status === "running") return { outcome: "pending" };
338
+ if (status.status !== "done" || typeof status.state_dir !== "string") {
339
+ return {
340
+ error: `review run ended with status ${String(status.status ?? "unknown")}`,
341
+ outcome: "review_failed",
342
+ stage: "review_run",
343
+ };
344
+ }
345
+ let input: DraftReviewInput;
346
+ let result: ReturnType<typeof parseDraftReviewResult>;
347
+ try {
348
+ input = JSON.parse(readFileSync(state.inputPath, "utf8")) as DraftReviewInput;
349
+ result = parseDraftReviewResult(
350
+ readFileSync(join(status.state_dir, "stdout.log"), "utf8"),
351
+ );
352
+ result = ReviewProjection.restoreDraftReviewResult(input, result);
353
+ } catch (error) {
354
+ return {
355
+ error: error instanceof Error ? error.message : String(error),
356
+ outcome: "review_failed",
357
+ stage: "result_parse",
358
+ };
359
+ }
360
+ const validation = validateDraftReviewResult(input, result);
361
+ if (!validation.ok) {
362
+ return {
363
+ error: validation.errors.join("; "),
364
+ outcome: "review_failed",
365
+ stage: "result_validation",
366
+ };
367
+ }
368
+ const draftRoot = dirname(input.drafts[0]!.path);
369
+ const capturedDrafts = new Map(input.drafts.map((draft) => [draft.path, draft]));
370
+ const plan: DraftConsolidationPlan = {
371
+ createdAt: result.createdAt,
372
+ cycleId: state.batchId,
373
+ drafts: result.decisions.map((decision) => {
374
+ const source = capturedDrafts.get(decision.draft);
375
+ if (decision.action === "promote" && !source?.recipe) {
376
+ throw new Error(`Promoted draft source recipe is unavailable: ${decision.draft}`);
377
+ }
378
+ return {
379
+ action: decision.action,
380
+ draft: decision.draft,
381
+ rationale: decision.rationale,
382
+ ...(decision.action === "promote" ? { recipe: source!.recipe } : {}),
383
+ sha256: decision.sha256,
384
+ ...(decision.target ? { target: decision.target } : {}),
385
+ ...(decision.action === "promote" ? { targetSha256: null } : {}),
386
+ };
387
+ }),
388
+ };
389
+ const inventory = input.drafts.map((draft) => ({
390
+ id: basename(draft.path).replace(/\.(?:json|md)$/u, ""),
391
+ path: draft.path,
392
+ sha256: draft.sha256,
393
+ valid: draft.valid,
394
+ ...(draft.diagnostics !== undefined ? { diagnostics: draft.diagnostics } : {}),
395
+ ...(draft.riskLabels.length > 0 ? { riskLabels: draft.riskLabels } : {}),
396
+ }));
397
+ try {
398
+ const transaction = DraftConsolidationTransaction.applyDraftConsolidationPlan(plan, {
399
+ cycleDir,
400
+ draftRoot,
401
+ inventory,
402
+ recipeRoot,
403
+ sourceScope: "batch",
404
+ });
405
+ return completeDraftSleepTransaction(transaction, state, deps, cycleDir, recipeRoot);
406
+ } catch (error) {
407
+ return {
408
+ error: error instanceof Error ? error.message : String(error),
409
+ outcome: "processing_failed",
410
+ stage: "transaction_or_lineage",
411
+ };
412
+ }
413
+ }
414
+
415
+ function cleanupCompletedState(state: DraftSleepState): void {
416
+ const cycleDir = dirname(state.inputPath);
417
+ rmSync(join(cycleDir, "quarantine"), { recursive: true, force: true });
418
+ rmSync(join(cycleDir, "backups"), { recursive: true, force: true });
419
+ if (!state.evidencePath || !existsSync(state.evidencePath)) return;
420
+ try {
421
+ const evidence = JSON.parse(readFileSync(state.evidencePath, "utf8")) as Record<string, unknown>;
422
+ const garbageCollection = evidence.garbageCollection as
423
+ | Record<string, unknown>
424
+ | undefined;
425
+ if (garbageCollection?.status === "completed") return;
426
+ writeJsonAtomic(state.evidencePath, {
427
+ ...evidence,
428
+ garbageCollection: {
429
+ ...garbageCollection,
430
+ status: "completed",
431
+ },
432
+ });
433
+ } catch {
434
+ /* bounded diagnostic evidence remains available for later inspection */
435
+ }
436
+ }
437
+
438
+ export function createDraftSleepScheduler(
439
+ deps: DraftSleepSchedulerDeps,
440
+ ): DraftSleepScheduler {
441
+ const statePath = deps.statePath ?? Paths.getDraftSleepStatePath();
442
+ const draftRoot = deps.draftRoot ?? Paths.getRecipeDraftRoot();
443
+ const recipeRoot = deps.recipeRoot ?? Paths.getRecipeRoot();
444
+ const now = deps.now ?? (() => new Date());
445
+ let timer: NodeJS.Timeout | undefined;
446
+ let closed = false;
447
+ const close = (): void => {
448
+ closed = true;
449
+ if (timer) clearTimeout(timer);
450
+ timer = undefined;
451
+ };
452
+ const schedule = (): void => {
453
+ if (closed || timer) return;
454
+ timer = setTimeout(() => {
455
+ timer = undefined;
456
+ if (closed || deps.hasActiveActors()) return;
457
+ try {
458
+ withFileMutationLock(statePath, () => {
459
+ const existing = readState(statePath);
460
+ if (existing?.phase === "completed") {
461
+ cleanupCompletedState(existing);
462
+ return;
463
+ }
464
+ if (
465
+ existing?.phase === "launched" ||
466
+ existing?.phase === "processing_failed"
467
+ ) {
468
+ if (!deps.process || (existing.processingAttempts ?? 0) >= 3) return;
469
+ let processed: DraftSleepProcessResult;
470
+ try {
471
+ processed = deps.process(existing);
472
+ } catch (error) {
473
+ processed = {
474
+ error: error instanceof Error ? error.message : String(error),
475
+ outcome: "processing_failed",
476
+ stage: "result_processing",
477
+ };
478
+ }
479
+ if (processed.outcome === "pending") return;
480
+ if (processed.outcome === "completed") {
481
+ const completed = {
482
+ ...existing,
483
+ ...(processed.evidencePath
484
+ ? { evidencePath: processed.evidencePath }
485
+ : {}),
486
+ failedStage: undefined,
487
+ lastError: undefined,
488
+ nextAction: undefined,
489
+ phase: "completed" as const,
490
+ updatedAt: now().toISOString(),
491
+ };
492
+ writeJsonAtomic(statePath, completed);
493
+ processed.cleanup?.();
494
+ return;
495
+ }
496
+ if (processed.outcome === "processing_failed") {
497
+ writeJsonAtomic(statePath, {
498
+ ...existing,
499
+ failedStage: processed.stage ?? "result_processing",
500
+ lastError: processed.error ?? "automatic draft review processing failed",
501
+ nextAction: "message to=tool:pi-actors type=review.retry body={\"scope\":\"draft\"}",
502
+ phase: "processing_failed",
503
+ processingAttempts: (existing.processingAttempts ?? 0) + 1,
504
+ updatedAt: now().toISOString(),
505
+ });
506
+ return;
507
+ }
508
+ writeJsonAtomic(statePath, {
509
+ ...existing,
510
+ failedStage: processed.stage ?? "review_result",
511
+ lastError: processed.error ?? "automatic draft reviewer failed",
512
+ nextAction: "message to=tool:pi-actors type=review.retry body={\"scope\":\"draft\"}",
513
+ phase: "failed",
514
+ runId: undefined,
515
+ updatedAt: now().toISOString(),
516
+ });
517
+ return;
518
+ }
519
+ let batch: DraftSleepBatch | undefined;
520
+ try {
521
+ batch = existing?.phase === "captured" || existing?.phase === "failed"
522
+ ? {
523
+ batchId: existing.batchId,
524
+ inputPath: existing.inputPath,
525
+ reviewerInputPath: existing.reviewerInputPath,
526
+ sourcePaths: existing.sourcePaths,
527
+ }
528
+ : captureDraftSleepBatchLocked(
529
+ deps,
530
+ draftRoot,
531
+ recipeRoot,
532
+ statePath,
533
+ );
534
+ if (!batch) return;
535
+ const attempts = (existing?.batchId === batch.batchId ? existing.attempts : 0) + 1;
536
+ if (attempts > 3) return;
537
+ const launched = deps.launch(batch);
538
+ writeJsonAtomic(statePath, {
539
+ attempts,
540
+ batchId: batch.batchId,
541
+ inputPath: batch.inputPath,
542
+ phase: "launched",
543
+ reviewerInputPath: batch.reviewerInputPath,
544
+ runId: launched.run,
545
+ sourcePaths: batch.sourcePaths,
546
+ updatedAt: now().toISOString(),
547
+ } satisfies DraftSleepState);
548
+ } catch (error) {
549
+ if (!batch) return;
550
+ const prior = readState(statePath);
551
+ writeJsonAtomic(statePath, {
552
+ attempts: (prior?.batchId === batch.batchId ? prior.attempts : 0) + 1,
553
+ failedStage: "review_launch",
554
+ lastError: error instanceof Error ? error.message : String(error),
555
+ nextAction: "message to=tool:pi-actors type=review.retry body={\"scope\":\"draft\"}",
556
+ batchId: batch.batchId,
557
+ inputPath: batch.inputPath,
558
+ phase: "failed",
559
+ reviewerInputPath: batch.reviewerInputPath,
560
+ sourcePaths: batch.sourcePaths,
561
+ updatedAt: now().toISOString(),
562
+ } satisfies DraftSleepState);
563
+ }
564
+ });
565
+ } catch {
566
+ /* another session owns the durable admission transition */
567
+ }
568
+ }, deps.delayMs ?? DEFAULT_DELAY_MS);
569
+ timer.unref?.();
570
+ };
571
+ return { close, schedule };
572
+ }
573
+
574
+ export function draftSleepRunId(batchId: string): string {
575
+ return `draft-sleep-${basename(batchId)}`;
576
+ }