From 826a7140456eed17c4182865c2795edebb60649d Mon Sep 17 00:00:00 2001 From: Roo Code Date: Fri, 19 Sep 2025 02:07:59 +0000 Subject: [PATCH 1/2] feat: add auto-cleanup for task history - Add configuration settings for auto-cleanup (enabled, maxCount, maxDiskSpaceMB, maxAgeDays) - Create TaskHistoryCleanupService to handle cleanup logic - Implement cleanup by count, disk space, and age thresholds - Integrate cleanup service into ClineProvider with idle-time execution - Add comprehensive tests for cleanup service - Cleanup runs automatically when thresholds are exceeded - Protects tasks from current workspace from deletion - Default settings: disabled, 100 tasks max, 1GB disk space, 7 days age Fixes #8172 --- packages/types/src/global-settings.ts | 6 + src/core/webview/ClineProvider.ts | 85 ++++ .../task-cleanup/TaskHistoryCleanupService.ts | 282 ++++++++++++++ .../TaskHistoryCleanupService.spec.ts | 368 ++++++++++++++++++ src/shared/ExtensionMessage.ts | 4 + 5 files changed, 745 insertions(+) create mode 100644 src/services/task-cleanup/TaskHistoryCleanupService.ts create mode 100644 src/services/task-cleanup/__tests__/TaskHistoryCleanupService.spec.ts diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 7e79855f7e..5175e8f580 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -151,6 +151,12 @@ export const globalSettingsSchema = z.object({ hasOpenedModeSelector: z.boolean().optional(), lastModeExportPath: z.string().optional(), lastModeImportPath: z.string().optional(), + + // Task history auto-cleanup settings + taskHistoryAutoCleanupEnabled: z.boolean().optional(), + taskHistoryMaxCount: z.number().min(0).optional(), + taskHistoryMaxDiskSpaceMB: z.number().min(0).optional(), + taskHistoryMaxAgeDays: z.number().min(0).optional(), }) export type GlobalSettings = z.infer diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 9abddc6d96..f5cc5cb643 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -68,6 +68,10 @@ import { ShadowCheckpointService } from "../../services/checkpoints/ShadowCheckp import { CodeIndexManager } from "../../services/code-index/manager" import type { IndexProgressUpdate } from "../../services/code-index/interfaces/manager" import { MdmService } from "../../services/mdm/MdmService" +import { + TaskHistoryCleanupService, + type TaskHistoryCleanupConfig, +} from "../../services/task-cleanup/TaskHistoryCleanupService" import { fileExistsAtPath } from "../../utils/fs" import { setTtsEnabled, setTtsSpeed } from "../../utils/tts" @@ -138,6 +142,7 @@ export class ClineProvider private recentTasksCache?: string[] private pendingOperations: Map = new Map() private static readonly PENDING_OPERATION_TIMEOUT_MS = 30000 // 30 seconds + private taskHistoryCleanupService?: TaskHistoryCleanupService public isViewLaunched = false public settingsImportedAt?: number @@ -253,6 +258,12 @@ export class ClineProvider } else { this.log("CloudService not ready, deferring cloud profile sync") } + + // Initialize task history cleanup service + this.taskHistoryCleanupService = new TaskHistoryCleanupService( + this.context.globalStorageUri.fsPath, + this.log.bind(this), + ) } /** @@ -1783,6 +1794,10 @@ export class ClineProvider openRouterImageGenerationSelectedModel, openRouterUseMiddleOutTransform, featureRoomoteControlEnabled, + taskHistoryAutoCleanupEnabled, + taskHistoryMaxCount, + taskHistoryMaxDiskSpaceMB, + taskHistoryMaxAgeDays, } = await this.getState() const telemetryKey = process.env.POSTHOG_API_KEY @@ -1920,6 +1935,10 @@ export class ClineProvider openRouterImageGenerationSelectedModel, openRouterUseMiddleOutTransform, featureRoomoteControlEnabled, + taskHistoryAutoCleanupEnabled: taskHistoryAutoCleanupEnabled ?? false, + taskHistoryMaxCount: taskHistoryMaxCount ?? 100, + taskHistoryMaxDiskSpaceMB: taskHistoryMaxDiskSpaceMB ?? 1000, + taskHistoryMaxAgeDays: taskHistoryMaxAgeDays ?? 7, } } @@ -2166,9 +2185,75 @@ export class ClineProvider await this.updateGlobalState("taskHistory", history) this.recentTasksCache = undefined + // Trigger auto-cleanup after updating task history + void this.triggerAutoCleanup() + return history } + /** + * Triggers automatic cleanup of task history if configured + */ + private async triggerAutoCleanup(): Promise { + if (!this.taskHistoryCleanupService) { + return + } + + try { + const { + taskHistoryAutoCleanupEnabled, + taskHistoryMaxCount, + taskHistoryMaxDiskSpaceMB, + taskHistoryMaxAgeDays, + taskHistory, + } = await this.getState() + + const config: TaskHistoryCleanupConfig = { + enabled: taskHistoryAutoCleanupEnabled ?? false, + maxCount: taskHistoryMaxCount, + maxDiskSpaceMB: taskHistoryMaxDiskSpaceMB, + maxAgeDays: taskHistoryMaxAgeDays, + } + + // Check if cleanup should be triggered + if (this.taskHistoryCleanupService.shouldTriggerCleanup(taskHistory ?? [], config)) { + // Run cleanup in the background during idle time + setTimeout(async () => { + try { + const result = await this.taskHistoryCleanupService!.performCleanup( + taskHistory ?? [], + config, + async (updatedHistory) => { + await this.updateGlobalState("taskHistory", updatedHistory) + this.recentTasksCache = undefined + await this.postStateToWebview() + }, + this.deleteTaskWithId.bind(this), + ) + + if (result.deletedCount > 0) { + this.log( + `[ClineProvider] Auto-cleanup completed: deleted ${result.deletedCount} tasks, freed ${result.freedSpaceMB.toFixed(2)}MB`, + ) + } + + if (result.errors.length > 0) { + this.log(`[ClineProvider] Auto-cleanup errors: ${result.errors.join(", ")}`) + } + } catch (error) { + this.log( + `[ClineProvider] Auto-cleanup failed: ${error instanceof Error ? error.message : String(error)}`, + ) + } + }, 5000) // Run after 5 seconds to avoid blocking current operations + } + } catch (error) { + this.log( + `[ClineProvider] Failed to trigger auto-cleanup: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + // ContextProxy // @deprecated - Use `ContextProxy#setValue` instead. diff --git a/src/services/task-cleanup/TaskHistoryCleanupService.ts b/src/services/task-cleanup/TaskHistoryCleanupService.ts new file mode 100644 index 0000000000..900812da5f --- /dev/null +++ b/src/services/task-cleanup/TaskHistoryCleanupService.ts @@ -0,0 +1,282 @@ +import * as fs from "fs/promises" +import * as path from "path" +import * as vscode from "vscode" +import type { HistoryItem } from "@roo-code/types" +import { getTaskDirectoryPath } from "../../utils/storage" + +export interface TaskHistoryCleanupConfig { + enabled: boolean + maxCount?: number + maxDiskSpaceMB?: number + maxAgeDays?: number +} + +export interface CleanupResult { + deletedCount: number + freedSpaceMB: number + errors: string[] +} + +/** + * Service responsible for automatically cleaning up old task history + * to prevent disk space from expanding rapidly when checkpoints are enabled. + */ +export class TaskHistoryCleanupService { + private isRunning = false + private lastCleanupTime = 0 + private readonly MIN_CLEANUP_INTERVAL_MS = 60 * 60 * 1000 // 1 hour minimum between cleanups + + constructor( + private readonly globalStoragePath: string, + private readonly log: (message: string) => void, + ) {} + + /** + * Performs cleanup of task history based on configured thresholds. + * This method is idempotent and safe to call multiple times. + */ + public async performCleanup( + taskHistory: HistoryItem[], + config: TaskHistoryCleanupConfig, + updateTaskHistory: (history: HistoryItem[]) => Promise, + deleteTaskWithId: (id: string) => Promise, + ): Promise { + const result: CleanupResult = { + deletedCount: 0, + freedSpaceMB: 0, + errors: [], + } + + // Skip if cleanup is disabled + if (!config.enabled) { + return result + } + + // Skip if another cleanup is already running + if (this.isRunning) { + this.log("[TaskHistoryCleanupService] Cleanup already in progress, skipping") + return result + } + + // Skip if we've cleaned up too recently + const now = Date.now() + if (now - this.lastCleanupTime < this.MIN_CLEANUP_INTERVAL_MS) { + this.log("[TaskHistoryCleanupService] Skipping cleanup - too soon since last cleanup") + return result + } + + try { + this.isRunning = true + this.lastCleanupTime = now + this.log("[TaskHistoryCleanupService] Starting automatic cleanup") + + // Get current workspace path to avoid deleting tasks from current workspace + const currentWorkspace = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath + + // Filter tasks that are eligible for deletion (not from current workspace) + const eligibleTasks = taskHistory.filter((task) => { + // Keep tasks from current workspace + if (currentWorkspace && task.workspace === currentWorkspace) { + return false + } + // Keep tasks without timestamps (shouldn't happen but be safe) + if (!task.ts) { + return false + } + return true + }) + + // Sort tasks by timestamp (oldest first) + const sortedTasks = [...eligibleTasks].sort((a, b) => (a.ts || 0) - (b.ts || 0)) + + const tasksToDelete: HistoryItem[] = [] + + // 1. Check age-based cleanup + if (config.maxAgeDays !== undefined && config.maxAgeDays > 0) { + const cutoffTime = now - config.maxAgeDays * 24 * 60 * 60 * 1000 + for (const task of sortedTasks) { + if (task.ts && task.ts < cutoffTime) { + if (!tasksToDelete.includes(task)) { + tasksToDelete.push(task) + } + } + } + this.log( + `[TaskHistoryCleanupService] Found ${tasksToDelete.length} tasks older than ${config.maxAgeDays} days`, + ) + } + + // 2. Check count-based cleanup + if (config.maxCount !== undefined && config.maxCount > 0) { + const totalCount = taskHistory.length + if (totalCount > config.maxCount) { + const countToDelete = totalCount - config.maxCount + // Delete oldest tasks first + for (let i = 0; i < Math.min(countToDelete, sortedTasks.length); i++) { + if (!tasksToDelete.includes(sortedTasks[i])) { + tasksToDelete.push(sortedTasks[i]) + } + } + this.log( + `[TaskHistoryCleanupService] Task count (${totalCount}) exceeds limit (${config.maxCount}), marking ${countToDelete} for deletion`, + ) + } + } + + // 3. Check disk space-based cleanup + if (config.maxDiskSpaceMB !== undefined && config.maxDiskSpaceMB > 0) { + const totalSizeMB = await this.calculateTotalTaskSize(taskHistory) + if (totalSizeMB > config.maxDiskSpaceMB) { + this.log( + `[TaskHistoryCleanupService] Total size (${totalSizeMB.toFixed(2)}MB) exceeds limit (${config.maxDiskSpaceMB}MB)`, + ) + // Delete oldest tasks until we're under the limit + let currentSizeMB = totalSizeMB + for (const task of sortedTasks) { + if (currentSizeMB <= config.maxDiskSpaceMB) { + break + } + if (!tasksToDelete.includes(task)) { + const taskSizeMB = await this.getTaskSize(task.id) + tasksToDelete.push(task) + currentSizeMB -= taskSizeMB + result.freedSpaceMB += taskSizeMB + } + } + } + } + + // Delete the identified tasks + if (tasksToDelete.length > 0) { + this.log(`[TaskHistoryCleanupService] Deleting ${tasksToDelete.length} tasks`) + + for (const task of tasksToDelete) { + try { + // Calculate size before deletion for reporting + if (result.freedSpaceMB === 0) { + const sizeMB = await this.getTaskSize(task.id) + result.freedSpaceMB += sizeMB + } + + // Delete the task + await deleteTaskWithId(task.id) + result.deletedCount++ + this.log(`[TaskHistoryCleanupService] Deleted task ${task.id}`) + } catch (error) { + const errorMsg = `Failed to delete task ${task.id}: ${error instanceof Error ? error.message : String(error)}` + this.log(`[TaskHistoryCleanupService] ${errorMsg}`) + result.errors.push(errorMsg) + } + } + + // Update task history to remove deleted tasks + const remainingTasks = taskHistory.filter((task) => !tasksToDelete.some((t) => t.id === task.id)) + await updateTaskHistory(remainingTasks) + } + + this.log( + `[TaskHistoryCleanupService] Cleanup completed: deleted ${result.deletedCount} tasks, freed ${result.freedSpaceMB.toFixed(2)}MB`, + ) + } catch (error) { + const errorMsg = `Cleanup failed: ${error instanceof Error ? error.message : String(error)}` + this.log(`[TaskHistoryCleanupService] ${errorMsg}`) + result.errors.push(errorMsg) + } finally { + this.isRunning = false + } + + return result + } + + /** + * Calculates the total disk space used by all tasks in MB + */ + private async calculateTotalTaskSize(taskHistory: HistoryItem[]): Promise { + let totalSizeMB = 0 + for (const task of taskHistory) { + try { + const sizeMB = await this.getTaskSize(task.id) + totalSizeMB += sizeMB + } catch (error) { + // Task directory might not exist, skip it + this.log( + `[TaskHistoryCleanupService] Could not calculate size for task ${task.id}: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + return totalSizeMB + } + + /** + * Gets the size of a single task directory in MB + */ + private async getTaskSize(taskId: string): Promise { + try { + const taskDir = await getTaskDirectoryPath(this.globalStoragePath, taskId) + const size = await this.getDirectorySize(taskDir) + return size / (1024 * 1024) // Convert bytes to MB + } catch (error) { + return 0 + } + } + + /** + * Recursively calculates the size of a directory in bytes + */ + private async getDirectorySize(dirPath: string): Promise { + let totalSize = 0 + try { + const entries = await fs.readdir(dirPath, { withFileTypes: true }) + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name) + if (entry.isDirectory()) { + totalSize += await this.getDirectorySize(fullPath) + } else if (entry.isFile()) { + const stats = await fs.stat(fullPath) + totalSize += stats.size + } + } + } catch (error) { + // Directory might not exist or be inaccessible + this.log( + `[TaskHistoryCleanupService] Could not access directory ${dirPath}: ${error instanceof Error ? error.message : String(error)}`, + ) + } + return totalSize + } + + /** + * Checks if cleanup should be triggered based on current state and config + */ + public shouldTriggerCleanup(taskHistory: HistoryItem[], config: TaskHistoryCleanupConfig): boolean { + if (!config.enabled) { + return false + } + + // Check if enough time has passed since last cleanup + const now = Date.now() + if (now - this.lastCleanupTime < this.MIN_CLEANUP_INTERVAL_MS) { + return false + } + + // Check count threshold + if (config.maxCount !== undefined && config.maxCount > 0) { + if (taskHistory.length > config.maxCount) { + return true + } + } + + // Check age threshold - cleanup if we have old tasks + if (config.maxAgeDays !== undefined && config.maxAgeDays > 0) { + const cutoffTime = now - config.maxAgeDays * 24 * 60 * 60 * 1000 + const hasOldTasks = taskHistory.some((task) => task.ts && task.ts < cutoffTime) + if (hasOldTasks) { + return true + } + } + + // Note: We don't check disk space here as it's expensive to calculate + // Disk space cleanup will be checked during the actual cleanup process + return false + } +} diff --git a/src/services/task-cleanup/__tests__/TaskHistoryCleanupService.spec.ts b/src/services/task-cleanup/__tests__/TaskHistoryCleanupService.spec.ts new file mode 100644 index 0000000000..a801668a6b --- /dev/null +++ b/src/services/task-cleanup/__tests__/TaskHistoryCleanupService.spec.ts @@ -0,0 +1,368 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import * as fs from "fs/promises" +import * as vscode from "vscode" +import type { HistoryItem } from "@roo-code/types" +import { TaskHistoryCleanupService, type TaskHistoryCleanupConfig } from "../TaskHistoryCleanupService" + +// Mock the storage utility +vi.mock("../../../utils/storage", () => ({ + getTaskDirectoryPath: vi.fn((globalStoragePath: string, taskId: string) => { + return Promise.resolve(`${globalStoragePath}/tasks/${taskId}`) + }), +})) + +// Mock fs module +vi.mock("fs/promises", () => ({ + default: { + readdir: vi.fn(), + stat: vi.fn(), + rm: vi.fn(), + }, + readdir: vi.fn(), + stat: vi.fn(), + rm: vi.fn(), +})) + +// Mock vscode +vi.mock("vscode", () => ({ + workspace: { + workspaceFolders: [{ uri: { fsPath: "/workspace" } }], + }, +})) + +describe("TaskHistoryCleanupService", () => { + let service: TaskHistoryCleanupService + let mockLog: ReturnType + let mockUpdateTaskHistory: ReturnType + let mockDeleteTaskWithId: ReturnType + const globalStoragePath = "/storage" + + beforeEach(() => { + vi.clearAllMocks() + mockLog = vi.fn() + mockUpdateTaskHistory = vi.fn().mockResolvedValue(undefined) + mockDeleteTaskWithId = vi.fn().mockResolvedValue(undefined) + service = new TaskHistoryCleanupService(globalStoragePath, mockLog) + + // Reset time-based mocks + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe("performCleanup", () => { + const createHistoryItem = (overrides: Partial = {}): HistoryItem => ({ + number: 1, + id: "task1", + ts: Date.now(), + task: "Test task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + workspace: "/other", + ...overrides, + }) + + it("should skip cleanup when disabled", async () => { + const config: TaskHistoryCleanupConfig = { + enabled: false, + } + const taskHistory: HistoryItem[] = [createHistoryItem({ id: "task1", task: "Test task 1" })] + + const result = await service.performCleanup( + taskHistory, + config, + mockUpdateTaskHistory, + mockDeleteTaskWithId, + ) + + expect(result.deletedCount).toBe(0) + expect(result.freedSpaceMB).toBe(0) + expect(mockDeleteTaskWithId).not.toHaveBeenCalled() + }) + + it("should skip cleanup if running too frequently", async () => { + const config: TaskHistoryCleanupConfig = { + enabled: true, + maxCount: 1, + } + const taskHistory: HistoryItem[] = [ + createHistoryItem({ id: "task1", task: "Test task 1" }), + createHistoryItem({ id: "task2", task: "Test task 2", number: 2 }), + ] + + // First cleanup + await service.performCleanup(taskHistory, config, mockUpdateTaskHistory, mockDeleteTaskWithId) + + // Try again immediately + const result = await service.performCleanup( + taskHistory, + config, + mockUpdateTaskHistory, + mockDeleteTaskWithId, + ) + + expect(result.deletedCount).toBe(0) + expect(mockLog).toHaveBeenCalledWith( + "[TaskHistoryCleanupService] Skipping cleanup - too soon since last cleanup", + ) + }) + + it("should not delete tasks from current workspace", async () => { + const config: TaskHistoryCleanupConfig = { + enabled: true, + maxCount: 1, + } + const taskHistory: HistoryItem[] = [ + createHistoryItem({ id: "task1", ts: Date.now() - 2000, task: "Old task", workspace: "/workspace" }), // Current workspace + createHistoryItem({ + id: "task2", + ts: Date.now() - 1000, + task: "New task", + number: 2, + workspace: "/other", + }), + ] + + // Mock file system operations + vi.mocked(fs.readdir).mockResolvedValue([]) + vi.mocked(fs.stat).mockResolvedValue({ size: 1024 * 1024 } as any) + + const result = await service.performCleanup( + taskHistory, + config, + mockUpdateTaskHistory, + mockDeleteTaskWithId, + ) + + // Should delete task2 (from /other) but not task1 (from current workspace) + expect(result.deletedCount).toBe(1) + expect(mockDeleteTaskWithId).toHaveBeenCalledWith("task2") + expect(mockDeleteTaskWithId).not.toHaveBeenCalledWith("task1") + }) + + it("should delete tasks older than maxAgeDays", async () => { + const now = Date.now() + const config: TaskHistoryCleanupConfig = { + enabled: true, + maxAgeDays: 7, + } + const taskHistory: HistoryItem[] = [ + createHistoryItem({ id: "task1", ts: now - 8 * 24 * 60 * 60 * 1000, task: "Old task" }), // 8 days old + createHistoryItem({ id: "task2", ts: now - 6 * 24 * 60 * 60 * 1000, task: "Recent task", number: 2 }), // 6 days old + ] + + // Mock file system operations + vi.mocked(fs.readdir).mockResolvedValue([]) + vi.mocked(fs.stat).mockResolvedValue({ size: 1024 * 1024 } as any) // 1MB + + const result = await service.performCleanup( + taskHistory, + config, + mockUpdateTaskHistory, + mockDeleteTaskWithId, + ) + + expect(result.deletedCount).toBe(1) + expect(mockDeleteTaskWithId).toHaveBeenCalledWith("task1") + expect(mockUpdateTaskHistory).toHaveBeenCalledWith([taskHistory[1]]) + }) + + it("should delete oldest tasks when count exceeds maxCount", async () => { + const now = Date.now() + const config: TaskHistoryCleanupConfig = { + enabled: true, + maxCount: 2, + } + const taskHistory: HistoryItem[] = [ + createHistoryItem({ id: "task1", ts: now - 3000, task: "Oldest" }), + createHistoryItem({ id: "task2", ts: now - 2000, task: "Middle", number: 2 }), + createHistoryItem({ id: "task3", ts: now - 1000, task: "Newest", number: 3 }), + ] + + // Mock file system operations + vi.mocked(fs.readdir).mockResolvedValue([]) + vi.mocked(fs.stat).mockResolvedValue({ size: 1024 * 1024 } as any) + + const result = await service.performCleanup( + taskHistory, + config, + mockUpdateTaskHistory, + mockDeleteTaskWithId, + ) + + expect(result.deletedCount).toBe(1) + expect(mockDeleteTaskWithId).toHaveBeenCalledWith("task1") + expect(mockUpdateTaskHistory).toHaveBeenCalledWith([taskHistory[1], taskHistory[2]]) + }) + + it("should delete tasks when disk space exceeds maxDiskSpaceMB", async () => { + const now = Date.now() + const config: TaskHistoryCleanupConfig = { + enabled: true, + maxDiskSpaceMB: 2, // 2MB limit + } + const taskHistory: HistoryItem[] = [ + createHistoryItem({ id: "task1", ts: now - 3000, task: "Task 1" }), + createHistoryItem({ id: "task2", ts: now - 2000, task: "Task 2", number: 2 }), + createHistoryItem({ id: "task3", ts: now - 1000, task: "Task 3", number: 3 }), + ] + + // Mock file system to return 1MB per task (3MB total) + vi.mocked(fs.readdir).mockResolvedValue([ + { name: "file1.txt", isDirectory: () => false, isFile: () => true } as any, + ]) + vi.mocked(fs.stat).mockResolvedValue({ size: 1024 * 1024 } as any) // 1MB per file + + const result = await service.performCleanup( + taskHistory, + config, + mockUpdateTaskHistory, + mockDeleteTaskWithId, + ) + + // Should delete oldest task to get under 2MB limit + expect(result.deletedCount).toBe(1) + expect(result.freedSpaceMB).toBeGreaterThan(0) + expect(mockDeleteTaskWithId).toHaveBeenCalledWith("task1") + }) + + it("should handle errors gracefully", async () => { + const config: TaskHistoryCleanupConfig = { + enabled: true, + maxCount: 1, + } + const taskHistory: HistoryItem[] = [ + createHistoryItem({ id: "task1", ts: Date.now() - 1000, task: "Task 1" }), + createHistoryItem({ id: "task2", task: "Task 2", number: 2 }), + ] + + // Mock deletion to fail + mockDeleteTaskWithId.mockRejectedValue(new Error("Delete failed")) + + const result = await service.performCleanup( + taskHistory, + config, + mockUpdateTaskHistory, + mockDeleteTaskWithId, + ) + + expect(result.deletedCount).toBe(0) + expect(result.errors).toContain("Failed to delete task task1: Delete failed") + }) + + it("should combine multiple cleanup criteria", async () => { + const now = Date.now() + const config: TaskHistoryCleanupConfig = { + enabled: true, + maxCount: 2, + maxAgeDays: 5, + } + const taskHistory: HistoryItem[] = [ + createHistoryItem({ id: "task1", ts: now - 10 * 24 * 60 * 60 * 1000, task: "Very old" }), // 10 days + createHistoryItem({ id: "task2", ts: now - 6 * 24 * 60 * 60 * 1000, task: "Old", number: 2 }), // 6 days + createHistoryItem({ id: "task3", ts: now - 1000, task: "Recent 1", number: 3 }), + createHistoryItem({ id: "task4", ts: now, task: "Recent 2", number: 4 }), + ] + + // Mock file system operations + vi.mocked(fs.readdir).mockResolvedValue([]) + vi.mocked(fs.stat).mockResolvedValue({ size: 1024 * 1024 } as any) + + const result = await service.performCleanup( + taskHistory, + config, + mockUpdateTaskHistory, + mockDeleteTaskWithId, + ) + + // Should delete task1 (age) and task2 (age + count) + expect(result.deletedCount).toBe(2) + expect(mockDeleteTaskWithId).toHaveBeenCalledWith("task1") + expect(mockDeleteTaskWithId).toHaveBeenCalledWith("task2") + expect(mockUpdateTaskHistory).toHaveBeenCalledWith([taskHistory[2], taskHistory[3]]) + }) + }) + + describe("shouldTriggerCleanup", () => { + const createHistoryItem = (overrides: Partial = {}): HistoryItem => ({ + number: 1, + id: "task1", + ts: Date.now(), + task: "Test task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + workspace: "/other", + ...overrides, + }) + + it("should return false when disabled", () => { + const config: TaskHistoryCleanupConfig = { + enabled: false, + maxCount: 1, + } + const taskHistory: HistoryItem[] = [ + createHistoryItem({ id: "task1", task: "Task 1" }), + createHistoryItem({ id: "task2", task: "Task 2", number: 2 }), + ] + + expect(service.shouldTriggerCleanup(taskHistory, config)).toBe(false) + }) + + it("should return true when count exceeds maxCount", () => { + const config: TaskHistoryCleanupConfig = { + enabled: true, + maxCount: 1, + } + const taskHistory: HistoryItem[] = [ + createHistoryItem({ id: "task1", task: "Task 1" }), + createHistoryItem({ id: "task2", task: "Task 2", number: 2 }), + ] + + // Advance time to bypass the minimum interval check + vi.advanceTimersByTime(60 * 60 * 1000 + 1) + + expect(service.shouldTriggerCleanup(taskHistory, config)).toBe(true) + }) + + it("should return true when old tasks exist", () => { + const now = Date.now() + const config: TaskHistoryCleanupConfig = { + enabled: true, + maxAgeDays: 7, + } + const taskHistory: HistoryItem[] = [ + createHistoryItem({ id: "task1", ts: now - 8 * 24 * 60 * 60 * 1000, task: "Old task" }), + ] + + // Advance time to bypass the minimum interval check + vi.advanceTimersByTime(60 * 60 * 1000 + 1) + + expect(service.shouldTriggerCleanup(taskHistory, config)).toBe(true) + }) + + it("should return false when cleanup was run recently", () => { + const config: TaskHistoryCleanupConfig = { + enabled: true, + maxCount: 1, + } + const taskHistory: HistoryItem[] = [ + createHistoryItem({ id: "task1", task: "Task 1" }), + createHistoryItem({ id: "task2", task: "Task 2", number: 2 }), + ] + + // First check should trigger + vi.advanceTimersByTime(60 * 60 * 1000 + 1) + expect(service.shouldTriggerCleanup(taskHistory, config)).toBe(true) + + // Simulate a cleanup run + service["lastCleanupTime"] = Date.now() + + // Second check immediately after should not trigger + expect(service.shouldTriggerCleanup(taskHistory, config)).toBe(false) + }) + }) +}) diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index aaddc520cb..5dbe25823e 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -283,6 +283,10 @@ export type ExtensionState = Pick< | "maxDiagnosticMessages" | "openRouterImageGenerationSelectedModel" | "includeTaskHistoryInEnhance" + | "taskHistoryAutoCleanupEnabled" + | "taskHistoryMaxCount" + | "taskHistoryMaxDiskSpaceMB" + | "taskHistoryMaxAgeDays" > & { version: string clineMessages: ClineMessage[] From d4a370ef372d7857548ea73880f24deedfaa2a74 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Fri, 19 Sep 2025 02:19:51 +0000 Subject: [PATCH 2/2] fix: handle missing globalStorageUri in test environment - Add safety check for globalStorageUri before initializing TaskHistoryCleanupService - Prevents test failures when globalStorageUri is not mocked in test context --- src/core/webview/ClineProvider.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index f5cc5cb643..8e6bf74b14 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -260,10 +260,13 @@ export class ClineProvider } // Initialize task history cleanup service - this.taskHistoryCleanupService = new TaskHistoryCleanupService( - this.context.globalStorageUri.fsPath, - this.log.bind(this), - ) + // Only initialize if globalStorageUri is available (not in test environment) + if (this.context.globalStorageUri) { + this.taskHistoryCleanupService = new TaskHistoryCleanupService( + this.context.globalStorageUri.fsPath, + this.log.bind(this), + ) + } } /** @@ -2195,6 +2198,7 @@ export class ClineProvider * Triggers automatic cleanup of task history if configured */ private async triggerAutoCleanup(): Promise { + // Skip if cleanup service is not initialized (e.g., in test environment) if (!this.taskHistoryCleanupService) { return }