Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
99362f8
feat: task history retention purge uses provider delete path; checkpo…
hannesrudolph Nov 14, 2025
d073305
fix: address race condition in task purge by processing deletions seq…
hannesrudolph Nov 19, 2025
b4d3767
fix: update translations for task history retention settings
hannesrudolph Nov 19, 2025
6a49a20
fix: update settings.json translations for task history retention
hannesrudolph Nov 19, 2025
6e208d0
fix: update task history retention descriptions for multiple languages
hannesrudolph Nov 26, 2025
73ebc19
fix: remove unused import and fix translation issues
hannesrudolph Nov 26, 2025
943186a
fix: add missing props to About.spec.tsx tests
hannesrudolph Nov 26, 2025
b6d60b2
fix: remove 'as any' type casts in retention feature
hannesrudolph Nov 26, 2025
fb151aa
refactor(retention): narrow RetentionSetting type and optimize deleti…
hannesrudolph Nov 27, 2025
ab6d3ab
test(webview): align About component test props with new retention union
hannesrudolph Jan 20, 2026
82df612
test: stabilize checkpoint service temp dir cleanup
hannesrudolph Jan 20, 2026
32a6639
Update src/extension.ts
hannesrudolph Jan 20, 2026
3a5d03e
Update src/__tests__/task-history-retention.spec.ts
hannesrudolph Jan 20, 2026
48666c4
fix: clarify aboutRetention text - runs on extension activation, not …
daniel-lxs Jan 20, 2026
7d20cc0
feat: add task storage size display to settings
daniel-lxs Jan 20, 2026
2423706
i18n: add taskHistoryStorage translations to all locales
daniel-lxs Jan 20, 2026
db6b195
fix: add background purgeOldTasks call and remove duplicate NLS keys
hannesrudolph Jan 20, 2026
f109eb7
fix: resolve merge conflict, use startBackgroundRetentionPurge() for …
daniel-lxs Jan 20, 2026
d50d6a7
fix: handle non-existent workspace repo in checkpoint cleanup
daniel-lxs Jan 20, 2026
4729908
chore: move task history retention to app state
hannesrudolph Jan 20, 2026
51e1ab1
fix: address mrubens review feedback for task history retention
hannesrudolph Jan 20, 2026
f5f7272
fix: add missing task history retention translations
hannesrudolph Jan 20, 2026
69efab6
test(core): increase timeout for CustomToolRegistry clearCache test
hannesrudolph Jan 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ describe("CustomToolRegistry", () => {
const result = await registry.loadFromDirectory(TEST_FIXTURES_DIR)

expect(result.loaded).toContain("cached")
}, 30000)
}, 120_000)
})

describe.sequential("loadFromDirectories", () => {
Expand Down
19 changes: 19 additions & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ export const MAX_CHECKPOINT_TIMEOUT_SECONDS = 60
*/
export const DEFAULT_CHECKPOINT_TIMEOUT_SECONDS = 15

/**
* Allowed values for the task history retention setting.
* Stored as strings in most UI/extension flows.
*/
export const TASK_HISTORY_RETENTION_OPTIONS = ["never", "90", "60", "30", "7", "3"] as const

export type TaskHistoryRetentionSetting = (typeof TASK_HISTORY_RETENTION_OPTIONS)[number]

/**
* GlobalSettings
*/
Expand Down Expand Up @@ -184,6 +192,17 @@ export const globalSettingsSchema = z.object({
customSupportPrompts: customSupportPromptsSchema.optional(),
enhancementApiConfigId: z.string().optional(),
includeTaskHistoryInEnhance: z.boolean().optional(),
// Auto-delete task history on extension reload.
// Note: we accept `number` for backwards compatibility with older persisted state.
taskHistoryRetention: z.union([z.enum(TASK_HISTORY_RETENTION_OPTIONS), z.number()]).optional(),
// Calculated task history storage size info for the Settings > About page
taskHistorySize: z
.object({
totalBytes: z.number(),
taskCount: z.number(),
formattedSize: z.string(),
})
.optional(),
historyPreviewCollapsed: z.boolean().optional(),
reasoningBlockCollapsed: z.boolean().optional(),
/**
Expand Down
11 changes: 11 additions & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,7 @@ export type ExtensionState = Pick<
| "includeCurrentCost"
| "maxGitStatusFiles"
| "requestDelaySeconds"
| "taskHistoryRetention"
> & {
version: string
clineMessages: ClineMessage[]
Expand Down Expand Up @@ -332,6 +333,15 @@ export type ExtensionState = Pick<
marketplaceInstalledMetadata?: { project: Record<string, any>; global: Record<string, any> }
profileThresholds: Record<string, number>
hasOpenedModeSelector: boolean
/** Task history storage size info for the Settings > About page */
taskHistorySize?: {
/** Total size in bytes */
totalBytes: number
/** Number of task directories */
taskCount: number
/** Formatted size string (e.g., "12.34 MB") */
formattedSize: string
}
openRouterImageApiKey?: string
messageQueue?: QueuedMessage[]
lastShownAnnouncementId?: string
Expand Down Expand Up @@ -533,6 +543,7 @@ export interface WebviewMessage {
| "requestModes"
| "switchMode"
| "debugSetting"
| "refreshTaskHistorySize"
text?: string
editedMessageContent?: string
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud"
Expand Down
12 changes: 12 additions & 0 deletions src/__tests__/extension.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ vi.mock("../core/config/ContextProxy", () => ({
setValue: vi.fn(),
getValues: vi.fn().mockReturnValue({}),
getProviderSettings: vi.fn().mockReturnValue({}),
// Needed by retention purge on activation
globalStorageUri: { fsPath: "/tmp/roo-retention-test" },
}),
},
}))
Expand Down Expand Up @@ -157,6 +159,16 @@ vi.mock("../utils/autoImportSettings", () => ({
autoImportSettings: vi.fn().mockResolvedValue(undefined),
}))

// Avoid filesystem access during activation by stubbing background purge
vi.mock("../utils/task-history-retention", () => ({
startBackgroundRetentionPurge: vi.fn(),
}))

// Ensure storage base path resolves to provided path to avoid touching VS Code config
vi.mock("../utils/storage", () => ({
getStorageBasePath: (p: string) => Promise.resolve(p),
}))

vi.mock("../extension/api", () => ({
API: vi.fn().mockImplementation(() => ({})),
}))
Expand Down
265 changes: 265 additions & 0 deletions src/__tests__/task-history-retention.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
// npx vitest run __tests__/task-history-retention.spec.ts
import * as fs from "fs/promises"
import * as path from "path"
import * as os from "os"

import { describe, it, expect } from "vitest"

// Ensure purge uses the provided base path without touching VS Code config
vi.mock("../utils/storage", () => ({
getStorageBasePath: (p: string) => Promise.resolve(p),
}))

import { purgeOldTasks } from "../utils/task-history-retention"
import { GlobalFileNames } from "../shared/globalFileNames"

// Helpers
async function exists(p: string): Promise<boolean> {
try {
await fs.access(p)
return true
} catch {
return false
}
}

async function mkTempBase(): Promise<string> {
const base = await fs.mkdtemp(path.join(os.tmpdir(), "roo-retention-"))
// Ensure <base>/tasks exists
await fs.mkdir(path.join(base, "tasks"), { recursive: true })
return base
}

async function createTask(base: string, id: string, ts?: number | "invalid"): Promise<string> {
const dir = path.join(base, "tasks", id)
await fs.mkdir(dir, { recursive: true })
const metadataPath = path.join(dir, GlobalFileNames.taskMetadata)
const metadata = ts === "invalid" ? "{ invalid json" : JSON.stringify({ ts: ts ?? Date.now() }, null, 2)
await fs.writeFile(metadataPath, metadata, "utf8")
return dir
}

describe("utils/task-history-retention.ts purgeOldTasks()", () => {
it("purges tasks older than 7 days when retention is '7'", async () => {
const base = await mkTempBase()
try {
const now = Date.now()
const days = (n: number) => n * 24 * 60 * 60 * 1000

const old = await createTask(base, "task-8d", now - days(8))
const recent = await createTask(base, "task-6d", now - days(6))

const { purgedCount } = await purgeOldTasks("7", base, () => {}, false)
expect(purgedCount).toBe(1)
expect(await exists(old)).toBe(false)
expect(await exists(recent)).toBe(true)
} finally {
await fs.rm(base, { recursive: true, force: true })
}
})

it("purges tasks older than 3 days when retention is '3'", async () => {
const base = await mkTempBase()
try {
const now = Date.now()
const days = (n: number) => n * 24 * 60 * 60 * 1000

const old = await createTask(base, "task-4d", now - days(4))
const recent = await createTask(base, "task-2d", now - days(2))

const { purgedCount } = await purgeOldTasks("3", base, () => {}, false)
expect(purgedCount).toBe(1)
expect(await exists(old)).toBe(false)
expect(await exists(recent)).toBe(true)
} finally {
await fs.rm(base, { recursive: true, force: true })
}
})

it("does not delete anything in dry run mode but still reports purgedCount", async () => {
const base = await mkTempBase()
try {
const now = Date.now()
const days = (n: number) => n * 24 * 60 * 60 * 1000

const old = await createTask(base, "task-8d", now - days(8))
const recent = await createTask(base, "task-6d", now - days(6))

const { purgedCount } = await purgeOldTasks("7", base, () => {}, true)
expect(purgedCount).toBe(1)
// In dry run, nothing is deleted
expect(await exists(old)).toBe(true)
expect(await exists(recent)).toBe(true)
} finally {
await fs.rm(base, { recursive: true, force: true })
}
})

it("does nothing when retention is 'never'", async () => {
const base = await mkTempBase()
try {
const now = Date.now()
const oldTs = now - 45 * 24 * 60 * 60 * 1000 // 45 days ago
const t1 = await createTask(base, "task-old", oldTs)
const t2 = await createTask(base, "task-new", now)

const { purgedCount, cutoff } = await purgeOldTasks("never", base, () => {})

expect(purgedCount).toBe(0)
expect(cutoff).toBeNull()
expect(await exists(t1)).toBe(true)
expect(await exists(t2)).toBe(true)
} finally {
await fs.rm(base, { recursive: true, force: true })
}
})

it("purges tasks older than 30 days and keeps newer or invalid-metadata ones", async () => {
const base = await mkTempBase()
try {
const now = Date.now()
const days = (n: number) => n * 24 * 60 * 60 * 1000

// One older than 30 days => delete
const old = await createTask(base, "task-31d", now - days(31))
// One newer than 30 days => keep
const recent = await createTask(base, "task-29d", now - days(29))
// Invalid metadata => skipped (kept)
const invalid = await createTask(base, "task-invalid", "invalid")

const { purgedCount, cutoff } = await purgeOldTasks("30", base, () => {})

expect(typeof cutoff).toBe("number")
expect(purgedCount).toBe(1)
expect(await exists(old)).toBe(false)
expect(await exists(recent)).toBe(true)
expect(await exists(invalid)).toBe(true)
} finally {
await fs.rm(base, { recursive: true, force: true })
}
})

it("deletes orphan checkpoint-only directories regardless of age", async () => {
const base = await mkTempBase()
try {
const now = Date.now()
const days = (n: number) => n * 24 * 60 * 60 * 1000

// Create a normal task that is recent (should be kept)
const normalTask = await createTask(base, "task-normal", now - days(1))

// Create an orphan checkpoint-only directory (only has checkpoints/ subdirectory, no metadata)
const orphanDir = path.join(base, "tasks", "task-orphan-checkpoints")
await fs.mkdir(orphanDir, { recursive: true })
const checkpointsDir = path.join(orphanDir, "checkpoints")
await fs.mkdir(checkpointsDir, { recursive: true })
// Add a dummy file inside checkpoints to make it realistic
await fs.writeFile(path.join(checkpointsDir, "checkpoint-1.json"), "{}", "utf8")

// Create another orphan with just checkpoints (no other files)
const orphanDir2 = path.join(base, "tasks", "task-orphan-empty")
await fs.mkdir(orphanDir2, { recursive: true })
const checkpointsDir2 = path.join(orphanDir2, "checkpoints")
await fs.mkdir(checkpointsDir2, { recursive: true })

// Run purge with 7 day retention - orphans should be deleted regardless of age
const { purgedCount } = await purgeOldTasks("7", base, () => {})

// Orphan directories should be deleted even though they're "recent"
expect(await exists(orphanDir)).toBe(false)
expect(await exists(orphanDir2)).toBe(false)
// Normal task should still exist (it's recent)
expect(await exists(normalTask)).toBe(true)
// Should have deleted 2 orphan directories
expect(purgedCount).toBe(2)
} finally {
await fs.rm(base, { recursive: true, force: true })
}
})

it("does not delete directories with checkpoints AND other content", async () => {
const base = await mkTempBase()
try {
const now = Date.now()
const days = (n: number) => n * 24 * 60 * 60 * 1000

// Create a task directory with both checkpoints and other files (but recent, so should be kept)
const taskDir = path.join(base, "tasks", "task-with-content")
await fs.mkdir(taskDir, { recursive: true })
const checkpointsDir = path.join(taskDir, "checkpoints")
await fs.mkdir(checkpointsDir, { recursive: true })
await fs.writeFile(path.join(checkpointsDir, "checkpoint-1.json"), "{}", "utf8")
// Add other files (not just checkpoints)
await fs.writeFile(path.join(taskDir, "some-file.txt"), "content", "utf8")
// Note: No metadata file, so it's technically invalid but has content

const { purgedCount } = await purgeOldTasks("7", base, () => {})

// Should NOT be deleted because it has content besides checkpoints
expect(await exists(taskDir)).toBe(true)
expect(purgedCount).toBe(0)
} finally {
await fs.rm(base, { recursive: true, force: true })
}
})

it("falls back to directory mtime for legacy tasks without metadata", async () => {
const base = await mkTempBase()
try {
const now = Date.now()
const days = (n: number) => n * 24 * 60 * 60 * 1000

// Create a legacy task directory without any metadata file
const oldLegacyDir = path.join(base, "tasks", "task-legacy-old")
await fs.mkdir(oldLegacyDir, { recursive: true })
// Add some content file
await fs.writeFile(path.join(oldLegacyDir, "content.txt"), "old task", "utf8")
// Manually set mtime to 10 days ago by touching the directory
const oldTime = new Date(now - days(10))
await fs.utimes(oldLegacyDir, oldTime, oldTime)

// Create another legacy task that is recent
const recentLegacyDir = path.join(base, "tasks", "task-legacy-recent")
await fs.mkdir(recentLegacyDir, { recursive: true })
await fs.writeFile(path.join(recentLegacyDir, "content.txt"), "recent task", "utf8")
// This one has recent mtime (now)

// Run purge with 7 day retention
const { purgedCount } = await purgeOldTasks("7", base, () => {})

// Old legacy task should be deleted based on mtime
expect(await exists(oldLegacyDir)).toBe(false)
// Recent legacy task should be kept
expect(await exists(recentLegacyDir)).toBe(true)
expect(purgedCount).toBe(1)
} finally {
await fs.rm(base, { recursive: true, force: true })
}
})

it("prioritizes metadata timestamp over mtime when both exist", async () => {
const base = await mkTempBase()
try {
const now = Date.now()
const days = (n: number) => n * 24 * 60 * 60 * 1000

// Create task with old metadata ts but recent mtime
const taskDir = path.join(base, "tasks", "task-priority-test")
await fs.mkdir(taskDir, { recursive: true })
const metadataPath = path.join(taskDir, GlobalFileNames.taskMetadata)
// Metadata says it's 10 days old (should be deleted with 7 day retention)
const metadata = JSON.stringify({ ts: now - days(10) }, null, 2)
await fs.writeFile(metadataPath, metadata, "utf8")
// But directory mtime is recent (could happen after editing)
// (Directory mtime is automatically recent from mkdir/writeFile)

const { purgedCount } = await purgeOldTasks("7", base, () => {})

// Should be deleted based on metadata ts, not mtime
expect(await exists(taskDir)).toBe(false)
expect(purgedCount).toBe(1)
} finally {
await fs.rm(base, { recursive: true, force: true })
}
})
})
Loading
Loading