Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
8ffeaed
feat: task history retention purge uses provider delete path; checkpo…
hannesrudolph Nov 14, 2025
e6395f2
fix: address race condition in task purge by processing deletions seq…
hannesrudolph Nov 19, 2025
7b8555d
fix: update translations for task history retention settings
hannesrudolph Nov 19, 2025
5f08421
fix: update settings.json translations for task history retention
hannesrudolph Nov 19, 2025
f7298a0
fix: update task history retention descriptions for multiple languages
hannesrudolph Nov 26, 2025
be916ca
fix: remove unused import and fix translation issues
hannesrudolph Nov 26, 2025
fc04b9f
fix: add missing props to About.spec.tsx tests
hannesrudolph Nov 26, 2025
8b30590
fix: remove 'as any' type casts in retention feature
hannesrudolph Nov 26, 2025
dbf47c2
refactor(retention): narrow RetentionSetting type and optimize deleti…
hannesrudolph Nov 27, 2025
8c14275
test(webview): align About component test props with new retention union
hannesrudolph Jan 20, 2026
d1bdbd5
test: stabilize checkpoint service temp dir cleanup
hannesrudolph Jan 20, 2026
06df1ca
Update src/extension.ts
hannesrudolph Jan 20, 2026
64b08fb
Update src/__tests__/task-history-retention.spec.ts
hannesrudolph Jan 20, 2026
f145a52
fix: clarify aboutRetention text - runs on extension activation, not …
daniel-lxs Jan 20, 2026
b6f662f
feat: add task storage size display to settings
daniel-lxs Jan 20, 2026
86f29ec
i18n: add taskHistoryStorage translations to all locales
daniel-lxs Jan 20, 2026
33b58be
fix: add background purgeOldTasks call and remove duplicate NLS keys
hannesrudolph Jan 20, 2026
523abdb
fix: resolve merge conflict, use startBackgroundRetentionPurge() for …
daniel-lxs Jan 20, 2026
7c0ce3e
fix: handle non-existent workspace repo in checkpoint cleanup
daniel-lxs Jan 20, 2026
0ba7cda
chore: move task history retention to app state
hannesrudolph Jan 20, 2026
bf483c9
fix: address mrubens review feedback for task history retention
hannesrudolph Jan 20, 2026
7083b1a
fix: add missing task history retention translations
hannesrudolph Jan 20, 2026
1eb887d
test(core): increase timeout for CustomToolRegistry clearCache test
hannesrudolph Jan 20, 2026
56ac6ef
Update webview-ui/src/components/settings/SettingsView.tsx
hannesrudolph Jan 21, 2026
18894da
fix: address mrubens review on retention types and settings navigation
hannesrudolph Jan 21, 2026
b19ab45
perf: replace storage size calculation with fast task count
hannesrudolph Jan 26, 2026
1ee2ac9
chore: remove unnecessary backwards compat re-export
hannesrudolph Jan 26, 2026
c9cb00e
chore: remove unrelated formatBytes tests from task-storage-size
hannesrudolph Jan 26, 2026
4c173af
fix: add taskHistoryRetention default to initial state to fix persist…
hannesrudolph Jan 26, 2026
b5e3a3d
perf: optimize task history retention purge with parallel processing
hannesrudolph Jan 26, 2026
19efb33
feat: add automatic checkpoint culling for inactive tasks
hannesrudolph Jan 26, 2026
0540d27
fix: simplify retention purge notification - remove non-working View …
hannesrudolph Jan 26, 2026
69b6ba5
feat: add progress logging to checkpoint culling
hannesrudolph Jan 26, 2026
739f6c1
feat(history): move retention settings to history view with gear icon…
hannesrudolph Jan 27, 2026
fea999b
feat: improve retention UX with confirmation dialog and friendly mess…
hannesrudolph Jan 27, 2026
9c92200
chore: remove unused formatBytes.ts file
hannesrudolph Jan 27, 2026
30f2aa2
fix: address roomote review concerns
hannesrudolph Jan 29, 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
18 changes: 18 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 @@ -181,6 +189,16 @@ export const globalSettingsSchema = z.object({
customSupportPrompts: customSupportPromptsSchema.optional(),
enhancementApiConfigId: z.string().optional(),
includeTaskHistoryInEnhance: z.boolean().optional(),
// Auto-delete task history on extension reload.
taskHistoryRetention: z.enum(TASK_HISTORY_RETENTION_OPTIONS).optional(),
// Calculated task history count for the Settings > About page
// Note: Size calculation was removed for performance reasons - with large numbers of
// tasks (e.g., 9000+), recursively stat'ing every file caused significant delays.
taskHistorySize: z
.object({
taskCount: z.number(),
})
.optional(),
historyPreviewCollapsed: z.boolean().optional(),
reasoningBlockCollapsed: z.boolean().optional(),
/**
Expand Down
10 changes: 10 additions & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,7 @@ export type ExtensionState = Pick<
| "maxGitStatusFiles"
| "requestDelaySeconds"
| "showWorktreesInHomeScreen"
| "taskHistoryRetention"
> & {
version: string
clineMessages: ClineMessage[]
Expand Down Expand Up @@ -395,6 +396,14 @@ export type ExtensionState = Pick<
marketplaceInstalledMetadata?: { project: Record<string, any>; global: Record<string, any> }
profileThresholds: Record<string, number>
hasOpenedModeSelector: boolean
/** Task history count for the Settings > About page
* Note: Size calculation was removed for performance reasons - with large numbers of
* tasks (e.g., 9000+), recursively stat'ing every file caused significant delays.
*/
taskHistorySize?: {
/** Number of task directories */
taskCount: number
}
openRouterImageApiKey?: string
messageQueue?: QueuedMessage[]
lastShownAnnouncementId?: string
Expand Down Expand Up @@ -592,6 +601,7 @@ export interface WebviewMessage {
| "requestModes"
| "switchMode"
| "debugSetting"
| "refreshTaskHistorySize"
// Worktree messages
| "listWorktrees"
| "createWorktree"
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 })
}
})
Comment thread
hannesrudolph marked this conversation as resolved.

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 })
}
})
})
8 changes: 6 additions & 2 deletions src/activate/registerCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt
return openClineInNewTab({ context, outputChannel })
},
openInNewTab: () => openClineInNewTab({ context, outputChannel }),
settingsButtonClicked: () => {
settingsButtonClicked: (section?: string) => {
const visibleProvider = getVisibleProviderOrLog(outputChannel)

if (!visibleProvider) {
Expand All @@ -114,7 +114,11 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt

TelemetryService.instance.captureTitleButtonClicked("settings")

visibleProvider.postMessageToWebview({ type: "action", action: "settingsButtonClicked" })
visibleProvider.postMessageToWebview({
type: "action",
action: "settingsButtonClicked",
values: section ? { section } : undefined,
})
// Also explicitly post the visibility message to trigger scroll reliably
visibleProvider.postMessageToWebview({ type: "action", action: "didBecomeVisible" })
},
Expand Down
Loading
Loading