taskplane 0.25.8 → 0.27.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.
package/README.md CHANGED
@@ -71,14 +71,17 @@ pi install -l npm:taskplane
71
71
  ## Quickstart
72
72
 
73
73
  ### 1. Initialize a project (to scaffold settings)
74
+ This step assumes you have installed Taskplane using one of the above options.
75
+
74
76
  (NOTE: if 'my-project' is a monorepo, be sure to run git init first. Taskplane uses git worktrees to isolate agent coding until you're ready to merge back to your default branch.)
77
+
75
78
  ```bash
76
79
  cd my-project
77
80
  taskplane init
78
81
  ```
79
82
  You'll answer a few questions. You can usually just accept the defaults.
80
83
 
81
- This creates config files in `.pi/`, agent prompts, two example tasks, and adds `.gitignore` entries for runtime artifacts. On first install, init bootstraps global preferences at `~/.pi/agent/taskplane/preferences.json` with thinking defaults set to `high` for worker & reviewer, and off for merger. Init auto-detects whether you're in a single repo or a multi-repo workspace. See the [install tutorial](docs/tutorials/install.md) for workspace mode and other scenarios.
84
+ This creates: config files in `.pi/`, agent prompts, two example tasks, and adds `.gitignore` entries for runtime artifacts. On first install, init bootstraps global preferences at `~/.pi/agent/taskplane/preferences.json` with thinking defaults set to `high` for worker & reviewer, and off for merger. Init auto-detects whether you're in a monorepo or a polyrepo workspace. See the [install tutorial](docs/tutorials/install.md) for workspace mode and other scenarios.
82
85
 
83
86
  Already have a task folder (for example `docs/task-management`)? Use:
84
87
 
@@ -294,7 +294,6 @@ function displayProgress(state) {
294
294
  * Per RPC protocol spec: split on \n, strip optional trailing \r,
295
295
  * do NOT use Node readline (splits on U+2028/U+2029).
296
296
  *
297
- * Reuses the proven pattern from task-runner.ts:910-975.
298
297
  */
299
298
  function attachJsonlReader(stream, onLine) {
300
299
  const decoder = new StringDecoder("utf8");
package/bin/taskplane.mjs CHANGED
@@ -787,18 +787,24 @@ export async function collectInitAgentConfig({
787
787
  return initAgentConfig;
788
788
  }
789
789
 
790
+ /** Normalize a path string to forward slashes (for config file output). */
791
+ function fwdSlash(p) {
792
+ return typeof p === "string" ? p.replace(/\\/g, "/") : p;
793
+ }
794
+
790
795
  export function generateProjectConfig(vars, _initAgentConfig = null) {
796
+ const tasksRoot = fwdSlash(vars.tasks_root);
791
797
  const projectConfig = {
792
798
  configVersion: 1,
793
799
  taskRunner: {
794
800
  project: { name: vars.project_name, description: "" },
795
- paths: { tasks: vars.tasks_root },
801
+ paths: { tasks: tasksRoot },
796
802
  testing: { commands: buildTestingCommands(vars) },
797
803
  taskAreas: {
798
804
  [vars.default_area]: {
799
- path: vars.tasks_root,
805
+ path: tasksRoot,
800
806
  prefix: vars.default_prefix,
801
- context: `${vars.tasks_root}/CONTEXT.md`,
807
+ context: `${tasksRoot}/CONTEXT.md`,
802
808
  },
803
809
  },
804
810
  },
@@ -820,10 +826,11 @@ export function generateProjectConfig(vars, _initAgentConfig = null) {
820
826
  }
821
827
 
822
828
  function generateWorkspaceYaml(repoNames, defaultRepo, tasksRoot) {
829
+ const normalizedTasksRoot = fwdSlash(tasksRoot);
823
830
  const reposBlock = repoNames
824
831
  .map((name) => ` ${name}:\n path: "${name}"`)
825
832
  .join("\n");
826
- return `repos:\n${reposBlock}\nrouting:\n tasks_root: "${tasksRoot}"\n default_repo: "${defaultRepo}"\n task_packet_repo: "${defaultRepo}"\n`;
833
+ return `repos:\n${reposBlock}\nrouting:\n tasks_root: "${normalizedTasksRoot}"\n default_repo: "${defaultRepo}"\n task_packet_repo: "${defaultRepo}"\n`;
827
834
  }
828
835
 
829
836
  function readWorkspaceJson(configRepoRoot) {
@@ -1923,7 +1930,7 @@ async function cmdInit(args) {
1923
1930
  default_branch: "main",
1924
1931
  })),
1925
1932
  routing: {
1926
- tasks_root: vars.tasks_root,
1933
+ tasks_root: fwdSlash(vars.tasks_root),
1927
1934
  default_repo: configRepoName,
1928
1935
  strict: false,
1929
1936
  },
@@ -3184,7 +3191,7 @@ ${c.bold}Examples:${c.reset}
3184
3191
  ${c.bold}Getting started:${c.reset}
3185
3192
  1. pi install npm:taskplane # Install the pi package
3186
3193
  2. cd my-project && taskplane init # Scaffold project config
3187
- 3. pi # Start pi — /task and /orch are ready
3194
+ 3. pi # Start pi — /orch is ready
3188
3195
  `);
3189
3196
  }
3190
3197
 
@@ -1379,6 +1379,49 @@ function renderSupervisorConversation(supervisor) {
1379
1379
  $supervisorConversationSection.innerHTML = html;
1380
1380
  }
1381
1381
 
1382
+ /**
1383
+ * Human-readable labels for supervisor recovery action identifiers.
1384
+ * The supervisor LLM writes snake_case action names to actions.jsonl.
1385
+ * This map translates them to operator-friendly labels for the dashboard.
1386
+ */
1387
+ const RECOVERY_ACTION_LABELS = {
1388
+ // Conflict resolution
1389
+ conflict_resolve_checkout_ours: "Auto-resolved merge conflict (kept task changes)",
1390
+ conflict_resolve_checkout_theirs: "Auto-resolved merge conflict (kept base changes)",
1391
+ conflict_resolve_manual: "Manual conflict resolution applied",
1392
+
1393
+ // Merge agent
1394
+ merge_retry: "Retried merge agent",
1395
+ merge_session_kill: "Terminated stalled merge agent",
1396
+ merge_force: "Forced merge with partial results",
1397
+
1398
+ // Worker / task
1399
+ worker_wrap_up: "Sent wrap-up signal to stalled worker",
1400
+ task_retry: "Retried failed task",
1401
+ task_skip: "Skipped task — unblocked dependents",
1402
+ wave_force_merge: "Force-merged wave with mixed results",
1403
+
1404
+ // Git / worktree
1405
+ lock_clear: "Cleared stale git lock file",
1406
+ worktree_remove: "Removed stale worktree",
1407
+ worktree_prune: "Pruned stale worktrees",
1408
+
1409
+ // Batch lifecycle
1410
+ abort_hard: "Hard-aborted batch",
1411
+ batch_resume: "Resumed batch after recovery",
1412
+ supervisor_handoff: "Supervisor session handoff",
1413
+
1414
+ // Diagnostics (usually not shown — filtered as non-recovery)
1415
+ initial_status_check: "Checked initial batch status",
1416
+ completion_status_check: "Verified batch completion status",
1417
+ read_state: "Read batch state",
1418
+ };
1419
+
1420
+ /** Format a recovery action type string into a human-readable label. */
1421
+ function formatRecoveryActionLabel(type) {
1422
+ return RECOVERY_ACTION_LABELS[type] || type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
1423
+ }
1424
+
1382
1425
  /** Merge supervisor actions and Tier 0 recovery events into a unified timeline.
1383
1426
  * Actions from actions.jsonl and recovery events from events.jsonl are combined
1384
1427
  * and sorted chronologically (per R002: show both Tier 0 and supervisor actions).
@@ -1451,7 +1494,7 @@ function renderSupervisorActions(supervisor) {
1451
1494
  html += ` <div class="supervisor-action-right">`;
1452
1495
  html += ` <div class="supervisor-action-header">`;
1453
1496
  if (tier) html += `<span class="supervisor-action-tier">${tier}</span>`;
1454
- html += ` <span class="supervisor-action-type">${escapeHtml(type)}</span>`;
1497
+ html += ` <span class="supervisor-action-type" title="${escapeHtml(type)}">${escapeHtml(formatRecoveryActionLabel(type))}</span>`;
1455
1498
  if (target) html += `<span class="supervisor-action-target">${escapeHtml(target)}</span>`;
1456
1499
  if (outcome) html += `<span class="supervisor-action-outcome ${outcomeCls}">${escapeHtml(outcome)}</span>`;
1457
1500
  html += ` </div>`;
@@ -18,7 +18,7 @@
18
18
  * .pi/task-orchestrator.yaml — orchestrator-specific settings
19
19
  * .pi/task-runner.yaml — task areas, worker/reviewer config (shared)
20
20
  *
21
- * Usage: pi -e extensions/task-orchestrator.ts -e extensions/task-runner.ts
21
+ * Usage: pi -e extensions/task-orchestrator.ts
22
22
  */
23
23
 
24
24
  // Re-export all named exports for tests and other consumers
@@ -85,7 +85,8 @@ export function selectAbortTargetSessions(
85
85
  laneId: lane.laneId,
86
86
  taskId: currentTask?.taskId || null,
87
87
  worktreePath: lane.worktreePath,
88
- taskFolder: currentTask?.task.taskFolder || null,
88
+ // TP-169: Guard against null task stubs from reconstructAllocatedLanes
89
+ taskFolder: currentTask?.task?.taskFolder || null,
89
90
  });
90
91
  }
91
92
 
@@ -136,6 +136,23 @@ export interface AgentHostOptions {
136
136
  packet?: PacketPaths | null;
137
137
  /** Extra environment variables for the child process */
138
138
  env?: Record<string, string>;
139
+ /**
140
+ * Callback invoked when agent_end fires, before stdin is closed.
141
+ * Receives the last assistant message text.
142
+ * Return a string to send as a new prompt (re-prompt the agent),
143
+ * or null to close the session normally.
144
+ *
145
+ * @since TP-172
146
+ */
147
+ onPrematureExit?: (assistantMessage: string) => Promise<string | null>;
148
+ /**
149
+ * Maximum number of exit interceptions before forcing session close.
150
+ * Prevents infinite loops where the callback always returns a new prompt.
151
+ * Default: 2
152
+ *
153
+ * @since TP-172
154
+ */
155
+ maxExitInterceptions?: number;
139
156
  }
140
157
 
141
158
  /**
@@ -235,6 +252,7 @@ export function spawnAgent(
235
252
  const cliPath = resolvePiCliPath();
236
253
  const closeDelayMs = opts.closeDelayMs ?? 100;
237
254
  const timeoutMs = opts.timeoutMs ?? 0;
255
+ const maxExitInterceptions = opts.maxExitInterceptions ?? 3;
238
256
 
239
257
  // Build Pi CLI arguments
240
258
  const piArgs: string[] = [cliPath, "--mode", "rpc", "--no-session"];
@@ -275,6 +293,12 @@ export function spawnAgent(
275
293
  let contextUsage: AgentHostResult["contextUsage"] = null;
276
294
  let stderrBuffer = "";
277
295
  const STDERR_MAX = 2048;
296
+ /** Last assistant message text captured from message_end events (TP-172) */
297
+ let lastAssistantMessage = "";
298
+ /** Number of times exit interception has occurred (TP-172) */
299
+ let exitInterceptionCount = 0;
300
+ /** Whether the current turn had any tool calls (TP-172: text-only gate) */
301
+ let currentTurnHadToolCalls = false;
278
302
 
279
303
  // Timeout
280
304
  let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
@@ -538,6 +562,8 @@ export function spawnAgent(
538
562
  const content = extractAssistantText(event.message);
539
563
  if (content) {
540
564
  emitEvent("assistant_message", { text: truncatePayload(content, MAX_CONV_PAYLOAD_CHARS) });
565
+ // TP-172: Track last assistant message for exit interception
566
+ lastAssistantMessage = content;
541
567
  }
542
568
  }
543
569
  // Request session stats immediately on first assistant message,
@@ -560,6 +586,7 @@ export function spawnAgent(
560
586
  }
561
587
  case "tool_execution_start": {
562
588
  toolCalls++;
589
+ currentTurnHadToolCalls = true;
563
590
  const toolName = event.toolName || "tool";
564
591
  const argPreview = typeof event.args === "string" ? event.args.slice(0, 300) :
565
592
  (event.args && typeof Object.values(event.args)[0] === "string" ? String(Object.values(event.args)[0]).slice(0, 300) : "");
@@ -602,7 +629,79 @@ export function spawnAgent(
602
629
  }
603
630
  case "agent_end": {
604
631
  agentEnded = true;
605
- closeStdin();
632
+ // TP-172: Exit interception — intercept any exit when callback
633
+ // is provided and under limit. The callback (lane-runner) decides
634
+ // whether the worker made progress. We don't gate on tool calls
635
+ // because workers commonly use tools (reads/greps) then exit
636
+ // with a text declaration ("Now let me fix this:") without
637
+ // actually making the edit.
638
+ const shouldIntercept = opts.onPrematureExit
639
+ && exitInterceptionCount < maxExitInterceptions;
640
+ if (shouldIntercept) {
641
+ exitInterceptionCount++;
642
+ const INTERCEPTION_TIMEOUT_MS = 120_000; // 2 minute safety timeout
643
+ // Wrap in Promise.resolve().then() to catch synchronous throws
644
+ const interceptPromise = Promise.resolve().then(() =>
645
+ opts.onPrematureExit!(lastAssistantMessage));
646
+ const timeoutPromise = new Promise<null>((res) =>
647
+ setTimeout(() => res(null), INTERCEPTION_TIMEOUT_MS));
648
+ Promise.race([interceptPromise, timeoutPromise])
649
+ .then(
650
+ (newPrompt: string | null) => {
651
+ if (newPrompt && !stdinClosed && proc.stdin && !proc.stdin.destroyed) {
652
+ // Re-prompt the agent with supervisor guidance
653
+ agentEnded = false; // Reset for the new turn
654
+ currentTurnHadToolCalls = false; // Reset for new turn
655
+ proc.stdin.write(JSON.stringify({ type: "prompt", message: newPrompt }) + "\n");
656
+ emitEvent("exit_intercepted", {
657
+ interceptionCount: exitInterceptionCount,
658
+ assistantMessage: truncatePayload(lastAssistantMessage, 500),
659
+ supervisorConsulted: true,
660
+ action: "reprompt",
661
+ newPromptPreview: truncatePayload(newPrompt, MAX_CONV_PAYLOAD_CHARS),
662
+ });
663
+ } else {
664
+ // Callback returned null or stdin already closed — close session
665
+ const reason = stdinClosed ? "stdin_closed"
666
+ : newPrompt === null ? "callback_returned_null"
667
+ : "unknown";
668
+ emitEvent("exit_intercepted", {
669
+ interceptionCount: exitInterceptionCount,
670
+ assistantMessage: truncatePayload(lastAssistantMessage, 500),
671
+ supervisorConsulted: true,
672
+ action: "close",
673
+ reason,
674
+ });
675
+ closeStdin();
676
+ }
677
+ },
678
+ (err: unknown) => {
679
+ // Callback rejected — emit single diagnostic event and close
680
+ const msg = err instanceof Error ? err.message : String(err);
681
+ emitEvent("exit_intercepted", {
682
+ interceptionCount: exitInterceptionCount,
683
+ assistantMessage: truncatePayload(lastAssistantMessage, 500),
684
+ supervisorConsulted: false,
685
+ action: "close",
686
+ reason: "callback_error",
687
+ error: msg,
688
+ });
689
+ closeStdin();
690
+ },
691
+ );
692
+ } else {
693
+ // No callback, had tool calls, or interception limit reached — close normally
694
+ if (opts.onPrematureExit && exitInterceptionCount >= maxExitInterceptions) {
695
+ emitEvent("exit_intercepted", {
696
+ interceptionCount: exitInterceptionCount,
697
+ assistantMessage: truncatePayload(lastAssistantMessage, 500),
698
+ supervisorConsulted: false,
699
+ action: "close",
700
+ reason: "max_interceptions_reached",
701
+ });
702
+ }
703
+ closeStdin();
704
+ }
606
705
  break;
607
706
  }
608
707
  }
@@ -1,19 +1,26 @@
1
1
  /**
2
2
  * Artifact cleanup and log rotation for orchestrator runtime files.
3
3
  *
4
- * Three cleanup layers prevent unbounded disk growth:
4
+ * Five cleanup layers prevent unbounded disk growth:
5
5
  *
6
6
  * 1. **Post-Integrate Cleanup** — Deletes batch-specific telemetry and merge
7
7
  * result files after successful /orch-integrate. Scoped by batchId.
8
8
  *
9
- * 2. **Age-Based Preflight Sweep** — On /orch start, removes telemetry and
10
- * merge artifacts older than 7 days. Catches files missed by Layer 1
11
- * (e.g., aborted batches, manual branch deletions).
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).
12
13
  *
13
14
  * 3. **Size-Capped Log Rotation** — Rotates append-only supervisor logs
14
15
  * (events.jsonl, actions.jsonl) at a 5MB threshold during preflight.
15
16
  * Keeps one .old generation.
16
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
+ *
17
24
  * All cleanup is **non-fatal** — failures warn but never block execution.
18
25
  *
19
26
  * @module orch/cleanup
@@ -177,8 +184,8 @@ export function formatPostIntegrateCleanup(result: PostIntegrateCleanupResult):
177
184
 
178
185
  // ── Layer 2: Age-Based Preflight Sweep ──────────────────────────────
179
186
 
180
- /** Default max age for stale artifacts (7 days in milliseconds). */
181
- export const STALE_ARTIFACT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
187
+ /** Default max age for stale artifacts (3 days in milliseconds). */
188
+ export const STALE_ARTIFACT_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000;
182
189
 
183
190
  /**
184
191
  * Result of a preflight age-based sweep.
@@ -215,13 +222,16 @@ export interface SweepDeps {
215
222
  * - `.pi/telemetry/lane-prompt-*.txt` — temporary prompt files
216
223
  * - `.pi/merge-result-*.json` — merge result files
217
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
218
228
  *
219
229
  * Uses file mtime for age detection. Skips files modified within maxAgeMs.
220
230
  * If a batch is currently active (executing/merging), skips ALL cleanup.
221
231
  *
222
232
  * @param stateRoot - Root directory containing .pi/
223
233
  * @param deps - Injectable dependencies for testability
224
- * @param maxAgeMs - Maximum file age in milliseconds (default: 7 days)
234
+ * @param maxAgeMs - Maximum file age in milliseconds (default: 3 days)
225
235
  * @returns Sweep result with count and warnings
226
236
  */
227
237
  export function sweepStaleArtifacts(
@@ -289,7 +299,17 @@ export function sweepStaleArtifacts(
289
299
  (name.startsWith("merge-request-") && name.endsWith(".txt")),
290
300
  );
291
301
 
292
- // Sweep stale batch directories under a parent (mailbox, context-snapshots)
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)
293
313
  const sweepBatchDirs = (parentDir: string, label: string): void => {
294
314
  if (!existsSync(parentDir)) return;
295
315
  try {
@@ -318,6 +338,9 @@ export function sweepStaleArtifacts(
318
338
  // Sweep stale context-snapshot batch directories (.pi/context-snapshots/{batchId}/)
319
339
  sweepBatchDirs(join(stateRoot, ".pi", "context-snapshots"), "context-snapshots");
320
340
 
341
+ // Sweep stale verification snapshot directories (.pi/verification/{opId}/)
342
+ sweepBatchDirs(join(stateRoot, ".pi", "verification"), "verification");
343
+
321
344
  return result;
322
345
  }
323
346
 
@@ -336,7 +359,7 @@ export function formatPreflightSweep(result: PreflightSweepResult): string {
336
359
  const segments: string[] = [];
337
360
  if (result.staleFilesDeleted > 0) segments.push(`${result.staleFilesDeleted} stale artifact(s)`);
338
361
  if (result.staleDirsDeleted > 0) segments.push(`${result.staleDirsDeleted} stale mailbox dir(s)`);
339
- parts.push(`🧹 Preflight cleanup: removed ${segments.join(" and ")} (>7 days old)`);
362
+ parts.push(`🧹 Preflight cleanup: removed ${segments.join(" and ")} (>3 days old)`);
340
363
  }
341
364
  for (const warning of result.warnings) {
342
365
  parts.push(` ⚠️ ${warning}`);
@@ -424,6 +447,245 @@ export function formatLogRotation(result: LogRotationResult): string {
424
447
  return parts.join("\n");
425
448
  }
426
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
+
427
689
  // ── Combined Preflight Cleanup ──────────────────────────────────────
428
690
 
429
691
  /**
@@ -466,7 +728,7 @@ export function formatPreflightCleanup(result: PreflightCleanupResult): string {
466
728
  const segments: string[] = [];
467
729
  if (result.sweep.staleFilesDeleted > 0) segments.push(`${result.sweep.staleFilesDeleted} stale artifact(s)`);
468
730
  if (result.sweep.staleDirsDeleted > 0) segments.push(`${result.sweep.staleDirsDeleted} stale mailbox dir(s)`);
469
- parts.push(`removed ${segments.join(" and ")} (>7 days old)`);
731
+ parts.push(`removed ${segments.join(" and ")} (>3 days old)`);
470
732
  }
471
733
 
472
734
  // Layer 3: log rotation