taskplane 0.28.4 → 0.28.6

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 (71) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +215 -215
  3. package/bin/gitignore-patterns.mjs +79 -79
  4. package/bin/rpc-wrapper.mjs +1086 -1086
  5. package/bin/taskplane.mjs +3254 -3254
  6. package/dashboard/public/app.js +2573 -2573
  7. package/dashboard/public/index.html +139 -139
  8. package/dashboard/public/style.css +1882 -1882
  9. package/dashboard/public/taskplane-word-color.svg +18 -18
  10. package/dashboard/public/taskplane-word-white.svg +18 -18
  11. package/dashboard/server.cjs +1666 -1666
  12. package/extensions/reviewer-extension.ts +119 -119
  13. package/extensions/task-orchestrator.ts +28 -28
  14. package/extensions/taskplane/abort.ts +502 -502
  15. package/extensions/taskplane/agent-bridge-extension.ts +838 -765
  16. package/extensions/taskplane/agent-host.ts +833 -745
  17. package/extensions/taskplane/cleanup.ts +747 -747
  18. package/extensions/taskplane/config-loader.ts +1328 -1322
  19. package/extensions/taskplane/config-schema.ts +692 -682
  20. package/extensions/taskplane/config.ts +73 -73
  21. package/extensions/taskplane/context-window.ts +66 -66
  22. package/extensions/taskplane/diagnostic-reports.ts +463 -463
  23. package/extensions/taskplane/diagnostics.ts +385 -385
  24. package/extensions/taskplane/engine-worker-entry.mjs +34 -34
  25. package/extensions/taskplane/engine-worker.ts +381 -381
  26. package/extensions/taskplane/engine.ts +4539 -4527
  27. package/extensions/taskplane/execution.ts +2733 -2708
  28. package/extensions/taskplane/extension.ts +30 -9
  29. package/extensions/taskplane/formatting.ts +773 -773
  30. package/extensions/taskplane/git.ts +90 -90
  31. package/extensions/taskplane/index.ts +28 -28
  32. package/extensions/taskplane/lane-runner.ts +1383 -1360
  33. package/extensions/taskplane/mailbox.ts +689 -689
  34. package/extensions/taskplane/merge.ts +3135 -3135
  35. package/extensions/taskplane/messages.ts +985 -985
  36. package/extensions/taskplane/migrations.ts +278 -278
  37. package/extensions/taskplane/naming.ts +117 -117
  38. package/extensions/taskplane/path-resolver.ts +237 -237
  39. package/extensions/taskplane/persistence.ts +2087 -2087
  40. package/extensions/taskplane/process-registry.ts +416 -416
  41. package/extensions/taskplane/quality-gate.ts +1033 -1033
  42. package/extensions/taskplane/resume.ts +2879 -2878
  43. package/extensions/taskplane/sessions.ts +57 -57
  44. package/extensions/taskplane/settings-loader.ts +136 -136
  45. package/extensions/taskplane/settings-tui.ts +1867 -1867
  46. package/extensions/taskplane/sidecar-telemetry.ts +252 -252
  47. package/extensions/taskplane/supervisor-primer.md +1694 -1694
  48. package/extensions/taskplane/supervisor.ts +4341 -4341
  49. package/extensions/taskplane/task-executor-core.ts +550 -550
  50. package/extensions/taskplane/tmux-compat.ts +37 -37
  51. package/extensions/taskplane/types.ts +4297 -4278
  52. package/extensions/taskplane/verification.ts +542 -542
  53. package/extensions/taskplane/waves.ts +1548 -1548
  54. package/extensions/taskplane/workspace.ts +705 -705
  55. package/extensions/taskplane/worktree.ts +2604 -2505
  56. package/package.json +57 -57
  57. package/skills/create-taskplane-task/SKILL.md +465 -465
  58. package/skills/create-taskplane-task/references/prompt-template.md +285 -285
  59. package/templates/agents/local/supervisor.md +33 -33
  60. package/templates/agents/local/task-merger.md +27 -27
  61. package/templates/agents/local/task-reviewer.md +30 -30
  62. package/templates/agents/local/task-worker.md +34 -34
  63. package/templates/agents/supervisor-routing.md +92 -92
  64. package/templates/agents/supervisor.md +168 -168
  65. package/templates/agents/task-merger.md +214 -214
  66. package/templates/agents/task-reviewer.md +192 -192
  67. package/templates/agents/task-worker.md +505 -429
  68. package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +98 -98
  69. package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -73
  70. package/templates/tasks/EXAMPLE-002-parallel-smoke/PROMPT.md +97 -97
  71. package/templates/tasks/EXAMPLE-002-parallel-smoke/STATUS.md +73 -73
@@ -1,747 +1,747 @@
1
- /**
2
- * Artifact cleanup and log rotation for orchestrator runtime files.
3
- *
4
- * Five cleanup layers prevent unbounded disk growth:
5
- *
6
- * 1. **Post-Integrate Cleanup** — Deletes batch-specific telemetry and merge
7
- * result files after successful /orch-integrate. Scoped by batchId.
8
- *
9
- * 2. **Age-Based Preflight Sweep** — On /orch start, removes telemetry,
10
- * verification, conversation, lane-state, and merge artifacts older than
11
- * 3 days. Catches files missed by Layer 1 (e.g., aborted batches,
12
- * manual branch deletions).
13
- *
14
- * 3. **Size-Capped Log Rotation** — Rotates append-only supervisor logs
15
- * (events.jsonl, actions.jsonl) at a 5MB threshold during preflight.
16
- * Keeps one .old generation.
17
- *
18
- * 4. **Telemetry Size Cap** — Enforces a 500MB cap on `.pi/telemetry/`
19
- * by evicting oldest files first when the directory exceeds the cap.
20
- *
21
- * 5. **Batch-Start Cleanup** — Removes artifacts from prior completed
22
- * batches when a new batch starts, protecting the current batch.
23
- *
24
- * All cleanup is **non-fatal** — failures warn but never block execution.
25
- *
26
- * @module orch/cleanup
27
- * @since TP-065
28
- */
29
- import { existsSync, readdirSync, statSync, unlinkSync, renameSync, mkdirSync, rmSync } from "fs";
30
- import { join } from "path";
31
- import { MAILBOX_DIR_NAME } from "./types.ts";
32
-
33
- // ── Layer 1: Post-Integrate Cleanup ─────────────────────────────────
34
-
35
- /**
36
- * Result of post-integrate artifact cleanup.
37
- */
38
- export interface PostIntegrateCleanupResult {
39
- /** Number of telemetry files deleted */
40
- telemetryFilesDeleted: number;
41
- /** Number of merge result/request files deleted */
42
- mergeFilesDeleted: number;
43
- /** Number of lane prompt files deleted */
44
- promptFilesDeleted: number;
45
- /** Number of mailbox batch directories deleted (0 or 1) */
46
- mailboxDirsDeleted: number;
47
- /** Number of context-snapshot batch directories deleted (0 or 1) */
48
- snapshotDirsDeleted: number;
49
- /** Warnings from non-fatal cleanup failures */
50
- warnings: string[];
51
- }
52
-
53
- /**
54
- * Clean up batch-specific telemetry and merge result files after integrate.
55
- *
56
- * Targets files whose names contain the batchId:
57
- * - `.pi/telemetry/*-{batchId}-*.jsonl` — worker/merger sidecar files
58
- * - `.pi/telemetry/*-{batchId}-*-exit.json` — exit summaries
59
- * - `.pi/telemetry/lane-prompt-*.txt` — temporary prompt files (all, not scoped)
60
- * - `.pi/merge-result-*-{batchId}.json` — merge result files
61
- * - `.pi/merge-request-*-{batchId}.txt` — merge request files
62
- *
63
- * @param stateRoot - Root directory containing .pi/ (workspace root or repo root)
64
- * @param batchId - Batch ID to scope deletion
65
- * @returns Cleanup result with counts and warnings
66
- */
67
- export function cleanupPostIntegrate(stateRoot: string, batchId: string): PostIntegrateCleanupResult {
68
- const result: PostIntegrateCleanupResult = {
69
- telemetryFilesDeleted: 0,
70
- mergeFilesDeleted: 0,
71
- promptFilesDeleted: 0,
72
- mailboxDirsDeleted: 0,
73
- snapshotDirsDeleted: 0,
74
- warnings: [],
75
- };
76
-
77
- if (!batchId) {
78
- result.warnings.push("No batchId provided — skipping post-integrate cleanup");
79
- return result;
80
- }
81
-
82
- // ── Telemetry files (.pi/telemetry/) ─────────────────────────
83
- const telemetryDir = join(stateRoot, ".pi", "telemetry");
84
- if (existsSync(telemetryDir)) {
85
- try {
86
- const entries = readdirSync(telemetryDir);
87
- for (const entry of entries) {
88
- // Delete batch-scoped sidecar/exit files containing the batchId
89
- if (entry.includes(batchId) && (entry.endsWith(".jsonl") || entry.endsWith("-exit.json"))) {
90
- try {
91
- unlinkSync(join(telemetryDir, entry));
92
- result.telemetryFilesDeleted++;
93
- } catch (err: unknown) {
94
- result.warnings.push(`Failed to delete telemetry file ${entry}: ${(err as Error).message}`);
95
- }
96
- }
97
- // Delete all lane-prompt-*.txt files (not batch-scoped — they're
98
- // temporary and should be cleaned up with any batch)
99
- if (entry.startsWith("lane-prompt-") && entry.endsWith(".txt")) {
100
- try {
101
- unlinkSync(join(telemetryDir, entry));
102
- result.promptFilesDeleted++;
103
- } catch (err: unknown) {
104
- result.warnings.push(`Failed to delete prompt file ${entry}: ${(err as Error).message}`);
105
- }
106
- }
107
- }
108
- } catch (err: unknown) {
109
- result.warnings.push(`Failed to read telemetry directory: ${(err as Error).message}`);
110
- }
111
- }
112
-
113
- // ── Merge result/request files (.pi/) ────────────────────────
114
- const piDir = join(stateRoot, ".pi");
115
- if (existsSync(piDir)) {
116
- try {
117
- const entries = readdirSync(piDir);
118
- for (const entry of entries) {
119
- if (entry.includes(batchId) && (
120
- (entry.startsWith("merge-result-") && entry.endsWith(".json")) ||
121
- (entry.startsWith("merge-request-") && entry.endsWith(".txt"))
122
- )) {
123
- try {
124
- unlinkSync(join(piDir, entry));
125
- result.mergeFilesDeleted++;
126
- } catch (err: unknown) {
127
- result.warnings.push(`Failed to delete merge file ${entry}: ${(err as Error).message}`);
128
- }
129
- }
130
- }
131
- } catch (err: unknown) {
132
- result.warnings.push(`Failed to read .pi directory: ${(err as Error).message}`);
133
- }
134
- }
135
-
136
- // ── Mailbox directory (.pi/mailbox/{batchId}/) ───────────
137
- const mailboxBatchDir = join(stateRoot, ".pi", MAILBOX_DIR_NAME, batchId);
138
- if (existsSync(mailboxBatchDir)) {
139
- try {
140
- rmSync(mailboxBatchDir, { recursive: true, force: true });
141
- result.mailboxDirsDeleted = 1;
142
- } catch (err: unknown) {
143
- result.warnings.push(`Failed to delete mailbox directory ${mailboxBatchDir}: ${(err as Error).message}`);
144
- }
145
- }
146
-
147
- // ── Context snapshots directory (.pi/context-snapshots/{batchId}/) ──────
148
- const snapshotBatchDir = join(stateRoot, ".pi", "context-snapshots", batchId);
149
- if (existsSync(snapshotBatchDir)) {
150
- try {
151
- rmSync(snapshotBatchDir, { recursive: true, force: true });
152
- result.snapshotDirsDeleted = 1;
153
- } catch (err: unknown) {
154
- result.warnings.push(`Failed to delete context-snapshots directory ${snapshotBatchDir}: ${(err as Error).message}`);
155
- }
156
- }
157
-
158
- return result;
159
- }
160
-
161
- /**
162
- * Format post-integrate cleanup result for user-facing notification.
163
- */
164
- export function formatPostIntegrateCleanup(result: PostIntegrateCleanupResult): string {
165
- const parts: string[] = [];
166
- const totalDeleted = result.telemetryFilesDeleted + result.mergeFilesDeleted + result.promptFilesDeleted + result.mailboxDirsDeleted + result.snapshotDirsDeleted;
167
-
168
- if (totalDeleted > 0) {
169
- const segments: string[] = [];
170
- if (result.telemetryFilesDeleted > 0) segments.push(`${result.telemetryFilesDeleted} telemetry`);
171
- if (result.mergeFilesDeleted > 0) segments.push(`${result.mergeFilesDeleted} merge`);
172
- if (result.promptFilesDeleted > 0) segments.push(`${result.promptFilesDeleted} prompt`);
173
- if (result.mailboxDirsDeleted > 0) segments.push(`${result.mailboxDirsDeleted} mailbox`);
174
- if (result.snapshotDirsDeleted > 0) segments.push(`${result.snapshotDirsDeleted} snapshots`);
175
- parts.push(`🧹 Cleaned up ${totalDeleted} artifact file(s): ${segments.join(", ")}`);
176
- }
177
-
178
- for (const warning of result.warnings) {
179
- parts.push(` ⚠️ ${warning}`);
180
- }
181
-
182
- return parts.join("\n");
183
- }
184
-
185
- // ── Layer 2: Age-Based Preflight Sweep ──────────────────────────────
186
-
187
- /** Default max age for stale artifacts (3 days in milliseconds). */
188
- export const STALE_ARTIFACT_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000;
189
-
190
- /**
191
- * Result of a preflight age-based sweep.
192
- */
193
- export interface PreflightSweepResult {
194
- /** Number of stale files deleted */
195
- staleFilesDeleted: number;
196
- /** Number of stale mailbox batch directories deleted */
197
- staleDirsDeleted: number;
198
- /** Whether the sweep was skipped (e.g., active batch) */
199
- skipped: boolean;
200
- /** Reason for skipping (if skipped) */
201
- skipReason?: string;
202
- /** Warnings from non-fatal cleanup failures */
203
- warnings: string[];
204
- }
205
-
206
- /**
207
- * Dependencies injected into sweepStaleArtifacts for testability.
208
- */
209
- export interface SweepDeps {
210
- /** Check if a batch is currently active (phase is not terminal). */
211
- isBatchActive: () => boolean;
212
- /** Get the current timestamp (for deterministic testing). */
213
- now: () => number;
214
- }
215
-
216
- /**
217
- * Sweep stale artifacts older than maxAgeMs during preflight.
218
- *
219
- * Targets:
220
- * - `.pi/telemetry/*.jsonl` — sidecar files
221
- * - `.pi/telemetry/*-exit.json` — exit summaries
222
- * - `.pi/telemetry/lane-prompt-*.txt` — temporary prompt files
223
- * - `.pi/merge-result-*.json` — merge result files
224
- * - `.pi/merge-request-*.txt` — merge request files
225
- * - `.pi/verification/*` — verification snapshots
226
- * - `.pi/worker-conversation-*.jsonl` — worker conversation logs
227
- * - `.pi/lane-state-*.json` — lane state files
228
- *
229
- * Uses file mtime for age detection. Skips files modified within maxAgeMs.
230
- * If a batch is currently active (executing/merging), skips ALL cleanup.
231
- *
232
- * @param stateRoot - Root directory containing .pi/
233
- * @param deps - Injectable dependencies for testability
234
- * @param maxAgeMs - Maximum file age in milliseconds (default: 3 days)
235
- * @returns Sweep result with count and warnings
236
- */
237
- export function sweepStaleArtifacts(
238
- stateRoot: string,
239
- deps: SweepDeps,
240
- maxAgeMs: number = STALE_ARTIFACT_MAX_AGE_MS,
241
- ): PreflightSweepResult {
242
- const result: PreflightSweepResult = {
243
- staleFilesDeleted: 0,
244
- staleDirsDeleted: 0,
245
- skipped: false,
246
- warnings: [],
247
- };
248
-
249
- // Guard: skip if batch is actively executing
250
- try {
251
- if (deps.isBatchActive()) {
252
- result.skipped = true;
253
- result.skipReason = "Active batch detected — skipping stale artifact sweep";
254
- return result;
255
- }
256
- } catch {
257
- // If we can't determine batch state, proceed cautiously
258
- }
259
-
260
- const now = deps.now();
261
- const cutoff = now - maxAgeMs;
262
-
263
- /**
264
- * Delete files older than cutoff from a directory, matching a filter.
265
- */
266
- const sweepDir = (dir: string, filter: (name: string) => boolean): void => {
267
- if (!existsSync(dir)) return;
268
- try {
269
- const entries = readdirSync(dir);
270
- for (const entry of entries) {
271
- if (!filter(entry)) continue;
272
- const filePath = join(dir, entry);
273
- try {
274
- const stat = statSync(filePath);
275
- if (!stat.isFile()) continue;
276
- if (stat.mtimeMs < cutoff) {
277
- unlinkSync(filePath);
278
- result.staleFilesDeleted++;
279
- }
280
- } catch (err: unknown) {
281
- result.warnings.push(`Failed to process ${entry}: ${(err as Error).message}`);
282
- }
283
- }
284
- } catch (err: unknown) {
285
- result.warnings.push(`Failed to read directory ${dir}: ${(err as Error).message}`);
286
- }
287
- };
288
-
289
- // Sweep telemetry files
290
- sweepDir(join(stateRoot, ".pi", "telemetry"), (name) =>
291
- name.endsWith(".jsonl") ||
292
- name.endsWith("-exit.json") ||
293
- (name.startsWith("lane-prompt-") && name.endsWith(".txt")),
294
- );
295
-
296
- // Sweep merge result/request files
297
- sweepDir(join(stateRoot, ".pi"), (name) =>
298
- (name.startsWith("merge-result-") && name.endsWith(".json")) ||
299
- (name.startsWith("merge-request-") && name.endsWith(".txt")),
300
- );
301
-
302
- // Sweep stale worker conversation logs (.pi/worker-conversation-*.jsonl)
303
- sweepDir(join(stateRoot, ".pi"), (name) =>
304
- name.startsWith("worker-conversation-") && name.endsWith(".jsonl"),
305
- );
306
-
307
- // Sweep stale lane state files (.pi/lane-state-*.json)
308
- sweepDir(join(stateRoot, ".pi"), (name) =>
309
- name.startsWith("lane-state-") && name.endsWith(".json"),
310
- );
311
-
312
- // Sweep stale batch directories under a parent (mailbox, context-snapshots, verification)
313
- const sweepBatchDirs = (parentDir: string, label: string): void => {
314
- if (!existsSync(parentDir)) return;
315
- try {
316
- const entries = readdirSync(parentDir);
317
- for (const entry of entries) {
318
- const entryPath = join(parentDir, entry);
319
- try {
320
- const stat = statSync(entryPath);
321
- if (!stat.isDirectory()) continue;
322
- if (stat.mtimeMs < cutoff) {
323
- rmSync(entryPath, { recursive: true, force: true });
324
- result.staleDirsDeleted++;
325
- }
326
- } catch (err: unknown) {
327
- result.warnings.push(`Failed to process ${label} dir ${entry}: ${(err as Error).message}`);
328
- }
329
- }
330
- } catch (err: unknown) {
331
- result.warnings.push(`Failed to read ${label} directory ${parentDir}: ${(err as Error).message}`);
332
- }
333
- };
334
-
335
- // Sweep stale mailbox batch directories (.pi/mailbox/{batchId}/)
336
- sweepBatchDirs(join(stateRoot, ".pi", MAILBOX_DIR_NAME), "mailbox");
337
-
338
- // Sweep stale context-snapshot batch directories (.pi/context-snapshots/{batchId}/)
339
- sweepBatchDirs(join(stateRoot, ".pi", "context-snapshots"), "context-snapshots");
340
-
341
- // Sweep stale verification snapshot directories (.pi/verification/{opId}/)
342
- sweepBatchDirs(join(stateRoot, ".pi", "verification"), "verification");
343
-
344
- return result;
345
- }
346
-
347
- /**
348
- * Format preflight sweep result for logging.
349
- */
350
- export function formatPreflightSweep(result: PreflightSweepResult): string {
351
- if (result.skipped) {
352
- return `ℹ️ Preflight sweep skipped: ${result.skipReason}`;
353
- }
354
- if (result.staleFilesDeleted === 0 && result.staleDirsDeleted === 0 && result.warnings.length === 0) {
355
- return ""; // Nothing to report
356
- }
357
- const parts: string[] = [];
358
- if (result.staleFilesDeleted > 0 || result.staleDirsDeleted > 0) {
359
- const segments: string[] = [];
360
- if (result.staleFilesDeleted > 0) segments.push(`${result.staleFilesDeleted} stale artifact(s)`);
361
- if (result.staleDirsDeleted > 0) segments.push(`${result.staleDirsDeleted} stale mailbox dir(s)`);
362
- parts.push(`🧹 Preflight cleanup: removed ${segments.join(" and ")} (>3 days old)`);
363
- }
364
- for (const warning of result.warnings) {
365
- parts.push(` ⚠️ ${warning}`);
366
- }
367
- return parts.join("\n");
368
- }
369
-
370
- // ── Layer 3: Size-Capped Log Rotation ───────────────────────────────
371
-
372
- /** Default rotation threshold: 5MB. */
373
- export const LOG_ROTATION_THRESHOLD_BYTES = 5 * 1024 * 1024;
374
-
375
- /**
376
- * Result of log rotation.
377
- */
378
- export interface LogRotationResult {
379
- /** Files that were rotated */
380
- rotated: string[];
381
- /** Warnings from non-fatal rotation failures */
382
- warnings: string[];
383
- }
384
-
385
- /**
386
- * Rotate supervisor append-only logs at a size threshold.
387
- *
388
- * Checks `events.jsonl` and `actions.jsonl` in `.pi/supervisor/`.
389
- * If a file exceeds the threshold, renames it to `.old` (overwriting
390
- * any existing `.old`), allowing a fresh file to be created on next write.
391
- *
392
- * Only call during preflight (not mid-batch).
393
- *
394
- * @param stateRoot - Root directory containing .pi/
395
- * @param thresholdBytes - Maximum file size before rotation (default: 5MB)
396
- * @returns Rotation result
397
- */
398
- export function rotateSupervisorLogs(
399
- stateRoot: string,
400
- thresholdBytes: number = LOG_ROTATION_THRESHOLD_BYTES,
401
- ): LogRotationResult {
402
- const result: LogRotationResult = {
403
- rotated: [],
404
- warnings: [],
405
- };
406
-
407
- const supervisorDir = join(stateRoot, ".pi", "supervisor");
408
- if (!existsSync(supervisorDir)) {
409
- return result; // Nothing to rotate
410
- }
411
-
412
- const filesToRotate = ["events.jsonl", "actions.jsonl"];
413
-
414
- for (const fileName of filesToRotate) {
415
- const filePath = join(supervisorDir, fileName);
416
- if (!existsSync(filePath)) continue;
417
-
418
- try {
419
- const stat = statSync(filePath);
420
- if (!stat.isFile() || stat.size <= thresholdBytes) continue;
421
-
422
- const oldPath = `${filePath}.old`;
423
- renameSync(filePath, oldPath);
424
- result.rotated.push(fileName);
425
- } catch (err: unknown) {
426
- result.warnings.push(`Failed to rotate ${fileName}: ${(err as Error).message}`);
427
- }
428
- }
429
-
430
- return result;
431
- }
432
-
433
- /**
434
- * Format log rotation result for logging.
435
- */
436
- export function formatLogRotation(result: LogRotationResult): string {
437
- if (result.rotated.length === 0 && result.warnings.length === 0) {
438
- return ""; // Nothing to report
439
- }
440
- const parts: string[] = [];
441
- if (result.rotated.length > 0) {
442
- parts.push(`🔄 Rotated ${result.rotated.length} supervisor log(s): ${result.rotated.join(", ")}`);
443
- }
444
- for (const warning of result.warnings) {
445
- parts.push(` ⚠️ ${warning}`);
446
- }
447
- return parts.join("\n");
448
- }
449
-
450
- // ── Layer 4: Telemetry Directory Size Cap ─────────────────────────────
451
-
452
- /** Default telemetry directory size cap: 500 MB. */
453
- export const TELEMETRY_SIZE_CAP_BYTES = 500 * 1024 * 1024;
454
-
455
- /**
456
- * Result of telemetry size cap enforcement.
457
- */
458
- export interface SizeCapResult {
459
- /** Number of files deleted to bring directory under cap */
460
- filesDeleted: number;
461
- /** Total bytes freed */
462
- bytesFreed: number;
463
- /** Warnings from non-fatal failures */
464
- warnings: string[];
465
- }
466
-
467
- /**
468
- * Enforce a size cap on the telemetry directory by evicting oldest files first.
469
- *
470
- * Scans `.pi/telemetry/` and sums file sizes. If the total exceeds `capBytes`,
471
- * deletes the oldest files (by mtime) until the total is under the cap.
472
- *
473
- * @param stateRoot - Root directory containing .pi/
474
- * @param capBytes - Maximum allowed total size in bytes (default: 500MB)
475
- * @returns Size cap enforcement result
476
- */
477
- export function enforceTelemetrySizeCap(
478
- stateRoot: string,
479
- capBytes: number = TELEMETRY_SIZE_CAP_BYTES,
480
- ): SizeCapResult {
481
- const result: SizeCapResult = {
482
- filesDeleted: 0,
483
- bytesFreed: 0,
484
- warnings: [],
485
- };
486
-
487
- const telemetryDir = join(stateRoot, ".pi", "telemetry");
488
- if (!existsSync(telemetryDir)) return result;
489
-
490
- // Collect all files with size and mtime
491
- interface FileEntry {
492
- name: string;
493
- path: string;
494
- size: number;
495
- mtimeMs: number;
496
- }
497
-
498
- const files: FileEntry[] = [];
499
- let totalSize = 0;
500
-
501
- try {
502
- const entries = readdirSync(telemetryDir);
503
- for (const entry of entries) {
504
- const filePath = join(telemetryDir, entry);
505
- try {
506
- const stat = statSync(filePath);
507
- if (!stat.isFile()) continue;
508
- files.push({ name: entry, path: filePath, size: stat.size, mtimeMs: stat.mtimeMs });
509
- totalSize += stat.size;
510
- } catch (err: unknown) {
511
- result.warnings.push(`Failed to stat ${entry}: ${(err as Error).message}`);
512
- }
513
- }
514
- } catch (err: unknown) {
515
- result.warnings.push(`Failed to read telemetry directory: ${(err as Error).message}`);
516
- return result;
517
- }
518
-
519
- if (totalSize <= capBytes) return result;
520
-
521
- // Sort oldest first (lowest mtime first)
522
- files.sort((a, b) => a.mtimeMs - b.mtimeMs);
523
-
524
- // Delete oldest files until under cap
525
- for (const file of files) {
526
- if (totalSize <= capBytes) break;
527
- try {
528
- unlinkSync(file.path);
529
- totalSize -= file.size;
530
- result.filesDeleted++;
531
- result.bytesFreed += file.size;
532
- } catch (err: unknown) {
533
- result.warnings.push(`Failed to delete ${file.name}: ${(err as Error).message}`);
534
- }
535
- }
536
-
537
- return result;
538
- }
539
-
540
- /**
541
- * Format size cap result for logging.
542
- */
543
- export function formatSizeCap(result: SizeCapResult): string {
544
- if (result.filesDeleted === 0 && result.warnings.length === 0) return "";
545
- const parts: string[] = [];
546
- if (result.filesDeleted > 0) {
547
- const mbFreed = (result.bytesFreed / (1024 * 1024)).toFixed(1);
548
- parts.push(`🧹 Telemetry size cap: deleted ${result.filesDeleted} file(s), freed ${mbFreed} MB`);
549
- }
550
- for (const warning of result.warnings) {
551
- parts.push(` ⚠️ ${warning}`);
552
- }
553
- return parts.join("\n");
554
- }
555
-
556
- // ── Layer 5: Batch-Start Cleanup of Prior Batch Artifacts ─────────────
557
-
558
- /**
559
- * Result of prior-batch artifact cleanup.
560
- */
561
- export interface PriorBatchCleanupResult {
562
- /** Number of files/dirs deleted */
563
- itemsDeleted: number;
564
- /** Warnings from non-fatal failures */
565
- warnings: string[];
566
- }
567
-
568
- /**
569
- * Clean up artifacts from prior completed batches when a new batch starts.
570
- *
571
- * Removes batch-scoped files that may have been left behind by prior runs
572
- * that were not integrated (e.g., aborted, crashed). Only cleans artifacts
573
- * from batches that are NOT the currently active batch.
574
- *
575
- * Targets the same file patterns as `cleanupPostIntegrate` plus stale
576
- * batch-state files.
577
- *
578
- * @param stateRoot - Root directory containing .pi/
579
- * @param currentBatchId - The batch ID that is currently starting (will NOT be deleted)
580
- * @returns Cleanup result
581
- */
582
- export function cleanupPriorBatchArtifacts(
583
- stateRoot: string,
584
- currentBatchId: string,
585
- ): PriorBatchCleanupResult {
586
- const result: PriorBatchCleanupResult = {
587
- itemsDeleted: 0,
588
- warnings: [],
589
- };
590
-
591
- if (!currentBatchId) {
592
- result.warnings.push("No currentBatchId provided — skipping prior batch cleanup");
593
- return result;
594
- }
595
-
596
- const piDir = join(stateRoot, ".pi");
597
- if (!existsSync(piDir)) return result;
598
-
599
- // Helper: delete files in a directory matching a filter, skipping current batch
600
- const cleanDir = (dir: string, filter: (name: string) => boolean): void => {
601
- if (!existsSync(dir)) return;
602
- try {
603
- const entries = readdirSync(dir);
604
- for (const entry of entries) {
605
- if (!filter(entry)) continue;
606
- if (entry.includes(currentBatchId)) continue; // Protect current batch
607
- const filePath = join(dir, entry);
608
- try {
609
- const stat = statSync(filePath);
610
- if (stat.isFile()) {
611
- unlinkSync(filePath);
612
- result.itemsDeleted++;
613
- }
614
- } catch (err: unknown) {
615
- result.warnings.push(`Failed to delete ${entry}: ${(err as Error).message}`);
616
- }
617
- }
618
- } catch (err: unknown) {
619
- result.warnings.push(`Failed to read directory ${dir}: ${(err as Error).message}`);
620
- }
621
- };
622
-
623
- // Clean telemetry files from prior batches
624
- cleanDir(join(piDir, "telemetry"), (name) =>
625
- name.endsWith(".jsonl") ||
626
- name.endsWith("-exit.json") ||
627
- (name.startsWith("lane-prompt-") && name.endsWith(".txt")),
628
- );
629
-
630
- // Clean merge result/request files from prior batches
631
- cleanDir(piDir, (name) =>
632
- (name.startsWith("merge-result-") && name.endsWith(".json")) ||
633
- (name.startsWith("merge-request-") && name.endsWith(".txt")),
634
- );
635
-
636
- // Clean worker conversation logs from prior batches
637
- cleanDir(piDir, (name) =>
638
- name.startsWith("worker-conversation-") && name.endsWith(".jsonl"),
639
- );
640
-
641
- // Clean lane state files from prior batches
642
- cleanDir(piDir, (name) =>
643
- name.startsWith("lane-state-") && name.endsWith(".json"),
644
- );
645
-
646
- // Clean batch-scoped directories (mailbox, context-snapshots)
647
- const cleanBatchDirs = (parentDir: string): void => {
648
- if (!existsSync(parentDir)) return;
649
- try {
650
- const entries = readdirSync(parentDir);
651
- for (const entry of entries) {
652
- if (entry === currentBatchId) continue; // Protect current batch
653
- const entryPath = join(parentDir, entry);
654
- try {
655
- const stat = statSync(entryPath);
656
- if (!stat.isDirectory()) continue;
657
- rmSync(entryPath, { recursive: true, force: true });
658
- result.itemsDeleted++;
659
- } catch (err: unknown) {
660
- result.warnings.push(`Failed to delete batch dir ${entry}: ${(err as Error).message}`);
661
- }
662
- }
663
- } catch (err: unknown) {
664
- result.warnings.push(`Failed to read directory ${parentDir}: ${(err as Error).message}`);
665
- }
666
- };
667
-
668
- cleanBatchDirs(join(piDir, MAILBOX_DIR_NAME));
669
- cleanBatchDirs(join(piDir, "context-snapshots"));
670
-
671
- return result;
672
- }
673
-
674
- /**
675
- * Format prior batch cleanup result for logging.
676
- */
677
- export function formatPriorBatchCleanup(result: PriorBatchCleanupResult): string {
678
- if (result.itemsDeleted === 0 && result.warnings.length === 0) return "";
679
- const parts: string[] = [];
680
- if (result.itemsDeleted > 0) {
681
- parts.push(`🧹 Prior batch cleanup: removed ${result.itemsDeleted} artifact(s) from previous batch(es)`);
682
- }
683
- for (const warning of result.warnings) {
684
- parts.push(` ⚠️ ${warning}`);
685
- }
686
- return parts.join("\n");
687
- }
688
-
689
- // ── Combined Preflight Cleanup ──────────────────────────────────────
690
-
691
- /**
692
- * Combined result of preflight cleanup (Layer 2 + Layer 3).
693
- */
694
- export interface PreflightCleanupResult {
695
- sweep: PreflightSweepResult;
696
- rotation: LogRotationResult;
697
- }
698
-
699
- /**
700
- * Run all preflight cleanup operations (Layer 2 + Layer 3).
701
- *
702
- * Called from the engine's preflight phase before batch starts.
703
- * Always non-fatal.
704
- *
705
- * @param stateRoot - Root directory containing .pi/
706
- * @param deps - Sweep dependencies (active batch check)
707
- * @returns Combined cleanup result
708
- */
709
- export function runPreflightCleanup(
710
- stateRoot: string,
711
- deps: SweepDeps,
712
- ): PreflightCleanupResult {
713
- const sweep = sweepStaleArtifacts(stateRoot, deps);
714
- const rotation = rotateSupervisorLogs(stateRoot);
715
- return { sweep, rotation };
716
- }
717
-
718
- /**
719
- * Format combined preflight cleanup result for user notification.
720
- *
721
- * Returns an empty string if nothing happened (no files cleaned/rotated).
722
- */
723
- export function formatPreflightCleanup(result: PreflightCleanupResult): string {
724
- const parts: string[] = [];
725
-
726
- // Layer 2: age-based sweep
727
- if (!result.sweep.skipped && (result.sweep.staleFilesDeleted > 0 || result.sweep.staleDirsDeleted > 0)) {
728
- const segments: string[] = [];
729
- if (result.sweep.staleFilesDeleted > 0) segments.push(`${result.sweep.staleFilesDeleted} stale artifact(s)`);
730
- if (result.sweep.staleDirsDeleted > 0) segments.push(`${result.sweep.staleDirsDeleted} stale mailbox dir(s)`);
731
- parts.push(`removed ${segments.join(" and ")} (>3 days old)`);
732
- }
733
-
734
- // Layer 3: log rotation
735
- if (result.rotation.rotated.length > 0) {
736
- parts.push(`rotated ${result.rotation.rotated.join(", ")} (>5 MB)`);
737
- }
738
-
739
- // Collect warnings from both layers
740
- const warnings = [...result.sweep.warnings, ...result.rotation.warnings];
741
- if (warnings.length > 0) {
742
- parts.push(`⚠️ ${warnings.length} cleanup warning(s)`);
743
- }
744
-
745
- if (parts.length === 0) return "";
746
- return `🧹 Preflight cleanup: ${parts.join("; ")}`;
747
- }
1
+ /**
2
+ * Artifact cleanup and log rotation for orchestrator runtime files.
3
+ *
4
+ * Five cleanup layers prevent unbounded disk growth:
5
+ *
6
+ * 1. **Post-Integrate Cleanup** — Deletes batch-specific telemetry and merge
7
+ * result files after successful /orch-integrate. Scoped by batchId.
8
+ *
9
+ * 2. **Age-Based Preflight Sweep** — On /orch start, removes telemetry,
10
+ * verification, conversation, lane-state, and merge artifacts older than
11
+ * 3 days. Catches files missed by Layer 1 (e.g., aborted batches,
12
+ * manual branch deletions).
13
+ *
14
+ * 3. **Size-Capped Log Rotation** — Rotates append-only supervisor logs
15
+ * (events.jsonl, actions.jsonl) at a 5MB threshold during preflight.
16
+ * Keeps one .old generation.
17
+ *
18
+ * 4. **Telemetry Size Cap** — Enforces a 500MB cap on `.pi/telemetry/`
19
+ * by evicting oldest files first when the directory exceeds the cap.
20
+ *
21
+ * 5. **Batch-Start Cleanup** — Removes artifacts from prior completed
22
+ * batches when a new batch starts, protecting the current batch.
23
+ *
24
+ * All cleanup is **non-fatal** — failures warn but never block execution.
25
+ *
26
+ * @module orch/cleanup
27
+ * @since TP-065
28
+ */
29
+ import { existsSync, readdirSync, statSync, unlinkSync, renameSync, mkdirSync, rmSync } from "fs";
30
+ import { join } from "path";
31
+ import { MAILBOX_DIR_NAME } from "./types.ts";
32
+
33
+ // ── Layer 1: Post-Integrate Cleanup ─────────────────────────────────
34
+
35
+ /**
36
+ * Result of post-integrate artifact cleanup.
37
+ */
38
+ export interface PostIntegrateCleanupResult {
39
+ /** Number of telemetry files deleted */
40
+ telemetryFilesDeleted: number;
41
+ /** Number of merge result/request files deleted */
42
+ mergeFilesDeleted: number;
43
+ /** Number of lane prompt files deleted */
44
+ promptFilesDeleted: number;
45
+ /** Number of mailbox batch directories deleted (0 or 1) */
46
+ mailboxDirsDeleted: number;
47
+ /** Number of context-snapshot batch directories deleted (0 or 1) */
48
+ snapshotDirsDeleted: number;
49
+ /** Warnings from non-fatal cleanup failures */
50
+ warnings: string[];
51
+ }
52
+
53
+ /**
54
+ * Clean up batch-specific telemetry and merge result files after integrate.
55
+ *
56
+ * Targets files whose names contain the batchId:
57
+ * - `.pi/telemetry/*-{batchId}-*.jsonl` — worker/merger sidecar files
58
+ * - `.pi/telemetry/*-{batchId}-*-exit.json` — exit summaries
59
+ * - `.pi/telemetry/lane-prompt-*.txt` — temporary prompt files (all, not scoped)
60
+ * - `.pi/merge-result-*-{batchId}.json` — merge result files
61
+ * - `.pi/merge-request-*-{batchId}.txt` — merge request files
62
+ *
63
+ * @param stateRoot - Root directory containing .pi/ (workspace root or repo root)
64
+ * @param batchId - Batch ID to scope deletion
65
+ * @returns Cleanup result with counts and warnings
66
+ */
67
+ export function cleanupPostIntegrate(stateRoot: string, batchId: string): PostIntegrateCleanupResult {
68
+ const result: PostIntegrateCleanupResult = {
69
+ telemetryFilesDeleted: 0,
70
+ mergeFilesDeleted: 0,
71
+ promptFilesDeleted: 0,
72
+ mailboxDirsDeleted: 0,
73
+ snapshotDirsDeleted: 0,
74
+ warnings: [],
75
+ };
76
+
77
+ if (!batchId) {
78
+ result.warnings.push("No batchId provided — skipping post-integrate cleanup");
79
+ return result;
80
+ }
81
+
82
+ // ── Telemetry files (.pi/telemetry/) ─────────────────────────
83
+ const telemetryDir = join(stateRoot, ".pi", "telemetry");
84
+ if (existsSync(telemetryDir)) {
85
+ try {
86
+ const entries = readdirSync(telemetryDir);
87
+ for (const entry of entries) {
88
+ // Delete batch-scoped sidecar/exit files containing the batchId
89
+ if (entry.includes(batchId) && (entry.endsWith(".jsonl") || entry.endsWith("-exit.json"))) {
90
+ try {
91
+ unlinkSync(join(telemetryDir, entry));
92
+ result.telemetryFilesDeleted++;
93
+ } catch (err: unknown) {
94
+ result.warnings.push(`Failed to delete telemetry file ${entry}: ${(err as Error).message}`);
95
+ }
96
+ }
97
+ // Delete all lane-prompt-*.txt files (not batch-scoped — they're
98
+ // temporary and should be cleaned up with any batch)
99
+ if (entry.startsWith("lane-prompt-") && entry.endsWith(".txt")) {
100
+ try {
101
+ unlinkSync(join(telemetryDir, entry));
102
+ result.promptFilesDeleted++;
103
+ } catch (err: unknown) {
104
+ result.warnings.push(`Failed to delete prompt file ${entry}: ${(err as Error).message}`);
105
+ }
106
+ }
107
+ }
108
+ } catch (err: unknown) {
109
+ result.warnings.push(`Failed to read telemetry directory: ${(err as Error).message}`);
110
+ }
111
+ }
112
+
113
+ // ── Merge result/request files (.pi/) ────────────────────────
114
+ const piDir = join(stateRoot, ".pi");
115
+ if (existsSync(piDir)) {
116
+ try {
117
+ const entries = readdirSync(piDir);
118
+ for (const entry of entries) {
119
+ if (entry.includes(batchId) && (
120
+ (entry.startsWith("merge-result-") && entry.endsWith(".json")) ||
121
+ (entry.startsWith("merge-request-") && entry.endsWith(".txt"))
122
+ )) {
123
+ try {
124
+ unlinkSync(join(piDir, entry));
125
+ result.mergeFilesDeleted++;
126
+ } catch (err: unknown) {
127
+ result.warnings.push(`Failed to delete merge file ${entry}: ${(err as Error).message}`);
128
+ }
129
+ }
130
+ }
131
+ } catch (err: unknown) {
132
+ result.warnings.push(`Failed to read .pi directory: ${(err as Error).message}`);
133
+ }
134
+ }
135
+
136
+ // ── Mailbox directory (.pi/mailbox/{batchId}/) ───────────
137
+ const mailboxBatchDir = join(stateRoot, ".pi", MAILBOX_DIR_NAME, batchId);
138
+ if (existsSync(mailboxBatchDir)) {
139
+ try {
140
+ rmSync(mailboxBatchDir, { recursive: true, force: true });
141
+ result.mailboxDirsDeleted = 1;
142
+ } catch (err: unknown) {
143
+ result.warnings.push(`Failed to delete mailbox directory ${mailboxBatchDir}: ${(err as Error).message}`);
144
+ }
145
+ }
146
+
147
+ // ── Context snapshots directory (.pi/context-snapshots/{batchId}/) ──────
148
+ const snapshotBatchDir = join(stateRoot, ".pi", "context-snapshots", batchId);
149
+ if (existsSync(snapshotBatchDir)) {
150
+ try {
151
+ rmSync(snapshotBatchDir, { recursive: true, force: true });
152
+ result.snapshotDirsDeleted = 1;
153
+ } catch (err: unknown) {
154
+ result.warnings.push(`Failed to delete context-snapshots directory ${snapshotBatchDir}: ${(err as Error).message}`);
155
+ }
156
+ }
157
+
158
+ return result;
159
+ }
160
+
161
+ /**
162
+ * Format post-integrate cleanup result for user-facing notification.
163
+ */
164
+ export function formatPostIntegrateCleanup(result: PostIntegrateCleanupResult): string {
165
+ const parts: string[] = [];
166
+ const totalDeleted = result.telemetryFilesDeleted + result.mergeFilesDeleted + result.promptFilesDeleted + result.mailboxDirsDeleted + result.snapshotDirsDeleted;
167
+
168
+ if (totalDeleted > 0) {
169
+ const segments: string[] = [];
170
+ if (result.telemetryFilesDeleted > 0) segments.push(`${result.telemetryFilesDeleted} telemetry`);
171
+ if (result.mergeFilesDeleted > 0) segments.push(`${result.mergeFilesDeleted} merge`);
172
+ if (result.promptFilesDeleted > 0) segments.push(`${result.promptFilesDeleted} prompt`);
173
+ if (result.mailboxDirsDeleted > 0) segments.push(`${result.mailboxDirsDeleted} mailbox`);
174
+ if (result.snapshotDirsDeleted > 0) segments.push(`${result.snapshotDirsDeleted} snapshots`);
175
+ parts.push(`🧹 Cleaned up ${totalDeleted} artifact file(s): ${segments.join(", ")}`);
176
+ }
177
+
178
+ for (const warning of result.warnings) {
179
+ parts.push(` ⚠️ ${warning}`);
180
+ }
181
+
182
+ return parts.join("\n");
183
+ }
184
+
185
+ // ── Layer 2: Age-Based Preflight Sweep ──────────────────────────────
186
+
187
+ /** Default max age for stale artifacts (3 days in milliseconds). */
188
+ export const STALE_ARTIFACT_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000;
189
+
190
+ /**
191
+ * Result of a preflight age-based sweep.
192
+ */
193
+ export interface PreflightSweepResult {
194
+ /** Number of stale files deleted */
195
+ staleFilesDeleted: number;
196
+ /** Number of stale mailbox batch directories deleted */
197
+ staleDirsDeleted: number;
198
+ /** Whether the sweep was skipped (e.g., active batch) */
199
+ skipped: boolean;
200
+ /** Reason for skipping (if skipped) */
201
+ skipReason?: string;
202
+ /** Warnings from non-fatal cleanup failures */
203
+ warnings: string[];
204
+ }
205
+
206
+ /**
207
+ * Dependencies injected into sweepStaleArtifacts for testability.
208
+ */
209
+ export interface SweepDeps {
210
+ /** Check if a batch is currently active (phase is not terminal). */
211
+ isBatchActive: () => boolean;
212
+ /** Get the current timestamp (for deterministic testing). */
213
+ now: () => number;
214
+ }
215
+
216
+ /**
217
+ * Sweep stale artifacts older than maxAgeMs during preflight.
218
+ *
219
+ * Targets:
220
+ * - `.pi/telemetry/*.jsonl` — sidecar files
221
+ * - `.pi/telemetry/*-exit.json` — exit summaries
222
+ * - `.pi/telemetry/lane-prompt-*.txt` — temporary prompt files
223
+ * - `.pi/merge-result-*.json` — merge result files
224
+ * - `.pi/merge-request-*.txt` — merge request files
225
+ * - `.pi/verification/*` — verification snapshots
226
+ * - `.pi/worker-conversation-*.jsonl` — worker conversation logs
227
+ * - `.pi/lane-state-*.json` — lane state files
228
+ *
229
+ * Uses file mtime for age detection. Skips files modified within maxAgeMs.
230
+ * If a batch is currently active (executing/merging), skips ALL cleanup.
231
+ *
232
+ * @param stateRoot - Root directory containing .pi/
233
+ * @param deps - Injectable dependencies for testability
234
+ * @param maxAgeMs - Maximum file age in milliseconds (default: 3 days)
235
+ * @returns Sweep result with count and warnings
236
+ */
237
+ export function sweepStaleArtifacts(
238
+ stateRoot: string,
239
+ deps: SweepDeps,
240
+ maxAgeMs: number = STALE_ARTIFACT_MAX_AGE_MS,
241
+ ): PreflightSweepResult {
242
+ const result: PreflightSweepResult = {
243
+ staleFilesDeleted: 0,
244
+ staleDirsDeleted: 0,
245
+ skipped: false,
246
+ warnings: [],
247
+ };
248
+
249
+ // Guard: skip if batch is actively executing
250
+ try {
251
+ if (deps.isBatchActive()) {
252
+ result.skipped = true;
253
+ result.skipReason = "Active batch detected — skipping stale artifact sweep";
254
+ return result;
255
+ }
256
+ } catch {
257
+ // If we can't determine batch state, proceed cautiously
258
+ }
259
+
260
+ const now = deps.now();
261
+ const cutoff = now - maxAgeMs;
262
+
263
+ /**
264
+ * Delete files older than cutoff from a directory, matching a filter.
265
+ */
266
+ const sweepDir = (dir: string, filter: (name: string) => boolean): void => {
267
+ if (!existsSync(dir)) return;
268
+ try {
269
+ const entries = readdirSync(dir);
270
+ for (const entry of entries) {
271
+ if (!filter(entry)) continue;
272
+ const filePath = join(dir, entry);
273
+ try {
274
+ const stat = statSync(filePath);
275
+ if (!stat.isFile()) continue;
276
+ if (stat.mtimeMs < cutoff) {
277
+ unlinkSync(filePath);
278
+ result.staleFilesDeleted++;
279
+ }
280
+ } catch (err: unknown) {
281
+ result.warnings.push(`Failed to process ${entry}: ${(err as Error).message}`);
282
+ }
283
+ }
284
+ } catch (err: unknown) {
285
+ result.warnings.push(`Failed to read directory ${dir}: ${(err as Error).message}`);
286
+ }
287
+ };
288
+
289
+ // Sweep telemetry files
290
+ sweepDir(join(stateRoot, ".pi", "telemetry"), (name) =>
291
+ name.endsWith(".jsonl") ||
292
+ name.endsWith("-exit.json") ||
293
+ (name.startsWith("lane-prompt-") && name.endsWith(".txt")),
294
+ );
295
+
296
+ // Sweep merge result/request files
297
+ sweepDir(join(stateRoot, ".pi"), (name) =>
298
+ (name.startsWith("merge-result-") && name.endsWith(".json")) ||
299
+ (name.startsWith("merge-request-") && name.endsWith(".txt")),
300
+ );
301
+
302
+ // Sweep stale worker conversation logs (.pi/worker-conversation-*.jsonl)
303
+ sweepDir(join(stateRoot, ".pi"), (name) =>
304
+ name.startsWith("worker-conversation-") && name.endsWith(".jsonl"),
305
+ );
306
+
307
+ // Sweep stale lane state files (.pi/lane-state-*.json)
308
+ sweepDir(join(stateRoot, ".pi"), (name) =>
309
+ name.startsWith("lane-state-") && name.endsWith(".json"),
310
+ );
311
+
312
+ // Sweep stale batch directories under a parent (mailbox, context-snapshots, verification)
313
+ const sweepBatchDirs = (parentDir: string, label: string): void => {
314
+ if (!existsSync(parentDir)) return;
315
+ try {
316
+ const entries = readdirSync(parentDir);
317
+ for (const entry of entries) {
318
+ const entryPath = join(parentDir, entry);
319
+ try {
320
+ const stat = statSync(entryPath);
321
+ if (!stat.isDirectory()) continue;
322
+ if (stat.mtimeMs < cutoff) {
323
+ rmSync(entryPath, { recursive: true, force: true });
324
+ result.staleDirsDeleted++;
325
+ }
326
+ } catch (err: unknown) {
327
+ result.warnings.push(`Failed to process ${label} dir ${entry}: ${(err as Error).message}`);
328
+ }
329
+ }
330
+ } catch (err: unknown) {
331
+ result.warnings.push(`Failed to read ${label} directory ${parentDir}: ${(err as Error).message}`);
332
+ }
333
+ };
334
+
335
+ // Sweep stale mailbox batch directories (.pi/mailbox/{batchId}/)
336
+ sweepBatchDirs(join(stateRoot, ".pi", MAILBOX_DIR_NAME), "mailbox");
337
+
338
+ // Sweep stale context-snapshot batch directories (.pi/context-snapshots/{batchId}/)
339
+ sweepBatchDirs(join(stateRoot, ".pi", "context-snapshots"), "context-snapshots");
340
+
341
+ // Sweep stale verification snapshot directories (.pi/verification/{opId}/)
342
+ sweepBatchDirs(join(stateRoot, ".pi", "verification"), "verification");
343
+
344
+ return result;
345
+ }
346
+
347
+ /**
348
+ * Format preflight sweep result for logging.
349
+ */
350
+ export function formatPreflightSweep(result: PreflightSweepResult): string {
351
+ if (result.skipped) {
352
+ return `ℹ️ Preflight sweep skipped: ${result.skipReason}`;
353
+ }
354
+ if (result.staleFilesDeleted === 0 && result.staleDirsDeleted === 0 && result.warnings.length === 0) {
355
+ return ""; // Nothing to report
356
+ }
357
+ const parts: string[] = [];
358
+ if (result.staleFilesDeleted > 0 || result.staleDirsDeleted > 0) {
359
+ const segments: string[] = [];
360
+ if (result.staleFilesDeleted > 0) segments.push(`${result.staleFilesDeleted} stale artifact(s)`);
361
+ if (result.staleDirsDeleted > 0) segments.push(`${result.staleDirsDeleted} stale mailbox dir(s)`);
362
+ parts.push(`🧹 Preflight cleanup: removed ${segments.join(" and ")} (>3 days old)`);
363
+ }
364
+ for (const warning of result.warnings) {
365
+ parts.push(` ⚠️ ${warning}`);
366
+ }
367
+ return parts.join("\n");
368
+ }
369
+
370
+ // ── Layer 3: Size-Capped Log Rotation ───────────────────────────────
371
+
372
+ /** Default rotation threshold: 5MB. */
373
+ export const LOG_ROTATION_THRESHOLD_BYTES = 5 * 1024 * 1024;
374
+
375
+ /**
376
+ * Result of log rotation.
377
+ */
378
+ export interface LogRotationResult {
379
+ /** Files that were rotated */
380
+ rotated: string[];
381
+ /** Warnings from non-fatal rotation failures */
382
+ warnings: string[];
383
+ }
384
+
385
+ /**
386
+ * Rotate supervisor append-only logs at a size threshold.
387
+ *
388
+ * Checks `events.jsonl` and `actions.jsonl` in `.pi/supervisor/`.
389
+ * If a file exceeds the threshold, renames it to `.old` (overwriting
390
+ * any existing `.old`), allowing a fresh file to be created on next write.
391
+ *
392
+ * Only call during preflight (not mid-batch).
393
+ *
394
+ * @param stateRoot - Root directory containing .pi/
395
+ * @param thresholdBytes - Maximum file size before rotation (default: 5MB)
396
+ * @returns Rotation result
397
+ */
398
+ export function rotateSupervisorLogs(
399
+ stateRoot: string,
400
+ thresholdBytes: number = LOG_ROTATION_THRESHOLD_BYTES,
401
+ ): LogRotationResult {
402
+ const result: LogRotationResult = {
403
+ rotated: [],
404
+ warnings: [],
405
+ };
406
+
407
+ const supervisorDir = join(stateRoot, ".pi", "supervisor");
408
+ if (!existsSync(supervisorDir)) {
409
+ return result; // Nothing to rotate
410
+ }
411
+
412
+ const filesToRotate = ["events.jsonl", "actions.jsonl"];
413
+
414
+ for (const fileName of filesToRotate) {
415
+ const filePath = join(supervisorDir, fileName);
416
+ if (!existsSync(filePath)) continue;
417
+
418
+ try {
419
+ const stat = statSync(filePath);
420
+ if (!stat.isFile() || stat.size <= thresholdBytes) continue;
421
+
422
+ const oldPath = `${filePath}.old`;
423
+ renameSync(filePath, oldPath);
424
+ result.rotated.push(fileName);
425
+ } catch (err: unknown) {
426
+ result.warnings.push(`Failed to rotate ${fileName}: ${(err as Error).message}`);
427
+ }
428
+ }
429
+
430
+ return result;
431
+ }
432
+
433
+ /**
434
+ * Format log rotation result for logging.
435
+ */
436
+ export function formatLogRotation(result: LogRotationResult): string {
437
+ if (result.rotated.length === 0 && result.warnings.length === 0) {
438
+ return ""; // Nothing to report
439
+ }
440
+ const parts: string[] = [];
441
+ if (result.rotated.length > 0) {
442
+ parts.push(`🔄 Rotated ${result.rotated.length} supervisor log(s): ${result.rotated.join(", ")}`);
443
+ }
444
+ for (const warning of result.warnings) {
445
+ parts.push(` ⚠️ ${warning}`);
446
+ }
447
+ return parts.join("\n");
448
+ }
449
+
450
+ // ── Layer 4: Telemetry Directory Size Cap ─────────────────────────────
451
+
452
+ /** Default telemetry directory size cap: 500 MB. */
453
+ export const TELEMETRY_SIZE_CAP_BYTES = 500 * 1024 * 1024;
454
+
455
+ /**
456
+ * Result of telemetry size cap enforcement.
457
+ */
458
+ export interface SizeCapResult {
459
+ /** Number of files deleted to bring directory under cap */
460
+ filesDeleted: number;
461
+ /** Total bytes freed */
462
+ bytesFreed: number;
463
+ /** Warnings from non-fatal failures */
464
+ warnings: string[];
465
+ }
466
+
467
+ /**
468
+ * Enforce a size cap on the telemetry directory by evicting oldest files first.
469
+ *
470
+ * Scans `.pi/telemetry/` and sums file sizes. If the total exceeds `capBytes`,
471
+ * deletes the oldest files (by mtime) until the total is under the cap.
472
+ *
473
+ * @param stateRoot - Root directory containing .pi/
474
+ * @param capBytes - Maximum allowed total size in bytes (default: 500MB)
475
+ * @returns Size cap enforcement result
476
+ */
477
+ export function enforceTelemetrySizeCap(
478
+ stateRoot: string,
479
+ capBytes: number = TELEMETRY_SIZE_CAP_BYTES,
480
+ ): SizeCapResult {
481
+ const result: SizeCapResult = {
482
+ filesDeleted: 0,
483
+ bytesFreed: 0,
484
+ warnings: [],
485
+ };
486
+
487
+ const telemetryDir = join(stateRoot, ".pi", "telemetry");
488
+ if (!existsSync(telemetryDir)) return result;
489
+
490
+ // Collect all files with size and mtime
491
+ interface FileEntry {
492
+ name: string;
493
+ path: string;
494
+ size: number;
495
+ mtimeMs: number;
496
+ }
497
+
498
+ const files: FileEntry[] = [];
499
+ let totalSize = 0;
500
+
501
+ try {
502
+ const entries = readdirSync(telemetryDir);
503
+ for (const entry of entries) {
504
+ const filePath = join(telemetryDir, entry);
505
+ try {
506
+ const stat = statSync(filePath);
507
+ if (!stat.isFile()) continue;
508
+ files.push({ name: entry, path: filePath, size: stat.size, mtimeMs: stat.mtimeMs });
509
+ totalSize += stat.size;
510
+ } catch (err: unknown) {
511
+ result.warnings.push(`Failed to stat ${entry}: ${(err as Error).message}`);
512
+ }
513
+ }
514
+ } catch (err: unknown) {
515
+ result.warnings.push(`Failed to read telemetry directory: ${(err as Error).message}`);
516
+ return result;
517
+ }
518
+
519
+ if (totalSize <= capBytes) return result;
520
+
521
+ // Sort oldest first (lowest mtime first)
522
+ files.sort((a, b) => a.mtimeMs - b.mtimeMs);
523
+
524
+ // Delete oldest files until under cap
525
+ for (const file of files) {
526
+ if (totalSize <= capBytes) break;
527
+ try {
528
+ unlinkSync(file.path);
529
+ totalSize -= file.size;
530
+ result.filesDeleted++;
531
+ result.bytesFreed += file.size;
532
+ } catch (err: unknown) {
533
+ result.warnings.push(`Failed to delete ${file.name}: ${(err as Error).message}`);
534
+ }
535
+ }
536
+
537
+ return result;
538
+ }
539
+
540
+ /**
541
+ * Format size cap result for logging.
542
+ */
543
+ export function formatSizeCap(result: SizeCapResult): string {
544
+ if (result.filesDeleted === 0 && result.warnings.length === 0) return "";
545
+ const parts: string[] = [];
546
+ if (result.filesDeleted > 0) {
547
+ const mbFreed = (result.bytesFreed / (1024 * 1024)).toFixed(1);
548
+ parts.push(`🧹 Telemetry size cap: deleted ${result.filesDeleted} file(s), freed ${mbFreed} MB`);
549
+ }
550
+ for (const warning of result.warnings) {
551
+ parts.push(` ⚠️ ${warning}`);
552
+ }
553
+ return parts.join("\n");
554
+ }
555
+
556
+ // ── Layer 5: Batch-Start Cleanup of Prior Batch Artifacts ─────────────
557
+
558
+ /**
559
+ * Result of prior-batch artifact cleanup.
560
+ */
561
+ export interface PriorBatchCleanupResult {
562
+ /** Number of files/dirs deleted */
563
+ itemsDeleted: number;
564
+ /** Warnings from non-fatal failures */
565
+ warnings: string[];
566
+ }
567
+
568
+ /**
569
+ * Clean up artifacts from prior completed batches when a new batch starts.
570
+ *
571
+ * Removes batch-scoped files that may have been left behind by prior runs
572
+ * that were not integrated (e.g., aborted, crashed). Only cleans artifacts
573
+ * from batches that are NOT the currently active batch.
574
+ *
575
+ * Targets the same file patterns as `cleanupPostIntegrate` plus stale
576
+ * batch-state files.
577
+ *
578
+ * @param stateRoot - Root directory containing .pi/
579
+ * @param currentBatchId - The batch ID that is currently starting (will NOT be deleted)
580
+ * @returns Cleanup result
581
+ */
582
+ export function cleanupPriorBatchArtifacts(
583
+ stateRoot: string,
584
+ currentBatchId: string,
585
+ ): PriorBatchCleanupResult {
586
+ const result: PriorBatchCleanupResult = {
587
+ itemsDeleted: 0,
588
+ warnings: [],
589
+ };
590
+
591
+ if (!currentBatchId) {
592
+ result.warnings.push("No currentBatchId provided — skipping prior batch cleanup");
593
+ return result;
594
+ }
595
+
596
+ const piDir = join(stateRoot, ".pi");
597
+ if (!existsSync(piDir)) return result;
598
+
599
+ // Helper: delete files in a directory matching a filter, skipping current batch
600
+ const cleanDir = (dir: string, filter: (name: string) => boolean): void => {
601
+ if (!existsSync(dir)) return;
602
+ try {
603
+ const entries = readdirSync(dir);
604
+ for (const entry of entries) {
605
+ if (!filter(entry)) continue;
606
+ if (entry.includes(currentBatchId)) continue; // Protect current batch
607
+ const filePath = join(dir, entry);
608
+ try {
609
+ const stat = statSync(filePath);
610
+ if (stat.isFile()) {
611
+ unlinkSync(filePath);
612
+ result.itemsDeleted++;
613
+ }
614
+ } catch (err: unknown) {
615
+ result.warnings.push(`Failed to delete ${entry}: ${(err as Error).message}`);
616
+ }
617
+ }
618
+ } catch (err: unknown) {
619
+ result.warnings.push(`Failed to read directory ${dir}: ${(err as Error).message}`);
620
+ }
621
+ };
622
+
623
+ // Clean telemetry files from prior batches
624
+ cleanDir(join(piDir, "telemetry"), (name) =>
625
+ name.endsWith(".jsonl") ||
626
+ name.endsWith("-exit.json") ||
627
+ (name.startsWith("lane-prompt-") && name.endsWith(".txt")),
628
+ );
629
+
630
+ // Clean merge result/request files from prior batches
631
+ cleanDir(piDir, (name) =>
632
+ (name.startsWith("merge-result-") && name.endsWith(".json")) ||
633
+ (name.startsWith("merge-request-") && name.endsWith(".txt")),
634
+ );
635
+
636
+ // Clean worker conversation logs from prior batches
637
+ cleanDir(piDir, (name) =>
638
+ name.startsWith("worker-conversation-") && name.endsWith(".jsonl"),
639
+ );
640
+
641
+ // Clean lane state files from prior batches
642
+ cleanDir(piDir, (name) =>
643
+ name.startsWith("lane-state-") && name.endsWith(".json"),
644
+ );
645
+
646
+ // Clean batch-scoped directories (mailbox, context-snapshots)
647
+ const cleanBatchDirs = (parentDir: string): void => {
648
+ if (!existsSync(parentDir)) return;
649
+ try {
650
+ const entries = readdirSync(parentDir);
651
+ for (const entry of entries) {
652
+ if (entry === currentBatchId) continue; // Protect current batch
653
+ const entryPath = join(parentDir, entry);
654
+ try {
655
+ const stat = statSync(entryPath);
656
+ if (!stat.isDirectory()) continue;
657
+ rmSync(entryPath, { recursive: true, force: true });
658
+ result.itemsDeleted++;
659
+ } catch (err: unknown) {
660
+ result.warnings.push(`Failed to delete batch dir ${entry}: ${(err as Error).message}`);
661
+ }
662
+ }
663
+ } catch (err: unknown) {
664
+ result.warnings.push(`Failed to read directory ${parentDir}: ${(err as Error).message}`);
665
+ }
666
+ };
667
+
668
+ cleanBatchDirs(join(piDir, MAILBOX_DIR_NAME));
669
+ cleanBatchDirs(join(piDir, "context-snapshots"));
670
+
671
+ return result;
672
+ }
673
+
674
+ /**
675
+ * Format prior batch cleanup result for logging.
676
+ */
677
+ export function formatPriorBatchCleanup(result: PriorBatchCleanupResult): string {
678
+ if (result.itemsDeleted === 0 && result.warnings.length === 0) return "";
679
+ const parts: string[] = [];
680
+ if (result.itemsDeleted > 0) {
681
+ parts.push(`🧹 Prior batch cleanup: removed ${result.itemsDeleted} artifact(s) from previous batch(es)`);
682
+ }
683
+ for (const warning of result.warnings) {
684
+ parts.push(` ⚠️ ${warning}`);
685
+ }
686
+ return parts.join("\n");
687
+ }
688
+
689
+ // ── Combined Preflight Cleanup ──────────────────────────────────────
690
+
691
+ /**
692
+ * Combined result of preflight cleanup (Layer 2 + Layer 3).
693
+ */
694
+ export interface PreflightCleanupResult {
695
+ sweep: PreflightSweepResult;
696
+ rotation: LogRotationResult;
697
+ }
698
+
699
+ /**
700
+ * Run all preflight cleanup operations (Layer 2 + Layer 3).
701
+ *
702
+ * Called from the engine's preflight phase before batch starts.
703
+ * Always non-fatal.
704
+ *
705
+ * @param stateRoot - Root directory containing .pi/
706
+ * @param deps - Sweep dependencies (active batch check)
707
+ * @returns Combined cleanup result
708
+ */
709
+ export function runPreflightCleanup(
710
+ stateRoot: string,
711
+ deps: SweepDeps,
712
+ ): PreflightCleanupResult {
713
+ const sweep = sweepStaleArtifacts(stateRoot, deps);
714
+ const rotation = rotateSupervisorLogs(stateRoot);
715
+ return { sweep, rotation };
716
+ }
717
+
718
+ /**
719
+ * Format combined preflight cleanup result for user notification.
720
+ *
721
+ * Returns an empty string if nothing happened (no files cleaned/rotated).
722
+ */
723
+ export function formatPreflightCleanup(result: PreflightCleanupResult): string {
724
+ const parts: string[] = [];
725
+
726
+ // Layer 2: age-based sweep
727
+ if (!result.sweep.skipped && (result.sweep.staleFilesDeleted > 0 || result.sweep.staleDirsDeleted > 0)) {
728
+ const segments: string[] = [];
729
+ if (result.sweep.staleFilesDeleted > 0) segments.push(`${result.sweep.staleFilesDeleted} stale artifact(s)`);
730
+ if (result.sweep.staleDirsDeleted > 0) segments.push(`${result.sweep.staleDirsDeleted} stale mailbox dir(s)`);
731
+ parts.push(`removed ${segments.join(" and ")} (>3 days old)`);
732
+ }
733
+
734
+ // Layer 3: log rotation
735
+ if (result.rotation.rotated.length > 0) {
736
+ parts.push(`rotated ${result.rotation.rotated.join(", ")} (>5 MB)`);
737
+ }
738
+
739
+ // Collect warnings from both layers
740
+ const warnings = [...result.sweep.warnings, ...result.rotation.warnings];
741
+ if (warnings.length > 0) {
742
+ parts.push(`⚠️ ${warnings.length} cleanup warning(s)`);
743
+ }
744
+
745
+ if (parts.length === 0) return "";
746
+ return `🧹 Preflight cleanup: ${parts.join("; ")}`;
747
+ }