Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit d63e72a

Browse files
committed
feat: task history retention purge uses provider delete path; checkpoint-only cleanup; quieter logging
1 parent 2b26cf3 commit d63e72a

13 files changed

Lines changed: 542 additions & 7 deletions

File tree

packages/types/src/global-settings.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,8 @@ export const globalSettingsSchema = z.object({
178178
customSupportPrompts: customSupportPromptsSchema.optional(),
179179
enhancementApiConfigId: z.string().optional(),
180180
includeTaskHistoryInEnhance: z.boolean().optional(),
181+
// Auto-delete task history on extension reload. "never" | "90" | "60" | "30" | "7" | "3"
182+
taskHistoryRetention: z.union([z.enum(["never", "90", "60", "30", "7", "3"]), z.number()]).optional(),
181183
historyPreviewCollapsed: z.boolean().optional(),
182184
reasoningBlockCollapsed: z.boolean().optional(),
183185
profileThresholds: z.record(z.string(), z.number()).optional(),

src/__tests__/extension.spec.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,8 @@ vi.mock("../core/config/ContextProxy", () => ({
109109
setValue: vi.fn(),
110110
getValues: vi.fn().mockReturnValue({}),
111111
getProviderSettings: vi.fn().mockReturnValue({}),
112+
// Needed by retention purge on activation
113+
globalStorageUri: { fsPath: "/tmp/roo-retention-test" },
112114
}),
113115
},
114116
}))
@@ -152,6 +154,16 @@ vi.mock("../utils/autoImportSettings", () => ({
152154
autoImportSettings: vi.fn().mockResolvedValue(undefined),
153155
}))
154156

157+
// Avoid filesystem access during activation by stubbing purge
158+
vi.mock("../utils/task-history-retention", () => ({
159+
purgeOldTasks: vi.fn().mockResolvedValue({ purgedCount: 0, cutoff: null }),
160+
}))
161+
162+
// Ensure storage base path resolves to provided path to avoid touching VS Code config
163+
vi.mock("../utils/storage", () => ({
164+
getStorageBasePath: (p: string) => Promise.resolve(p),
165+
}))
166+
155167
vi.mock("../extension/api", () => ({
156168
API: vi.fn().mockImplementation(() => ({})),
157169
}))
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// npx vitest run __tests__/task-history-retention.spec.ts
2+
import * as fs from "fs/promises"
3+
import * as path from "path"
4+
import * as os from "os"
5+
6+
import { describe, it, expect } from "vitest"
7+
8+
// Ensure purge uses the provided base path without touching VS Code config
9+
vi.mock("../utils/storage", () => ({
10+
getStorageBasePath: (p: string) => Promise.resolve(p),
11+
}))
12+
13+
import { purgeOldTasks } from "../utils/task-history-retention"
14+
import { GlobalFileNames } from "../shared/globalFileNames"
15+
16+
// Helpers
17+
async function exists(p: string): Promise<boolean> {
18+
try {
19+
await fs.access(p)
20+
return true
21+
} catch {
22+
return false
23+
}
24+
}
25+
26+
async function mkTempBase(): Promise<string> {
27+
const base = await fs.mkdtemp(path.join(os.tmpdir(), "roo-retention-"))
28+
// Ensure <base>/tasks exists
29+
await fs.mkdir(path.join(base, "tasks"), { recursive: true })
30+
return base
31+
}
32+
33+
async function createTask(base: string, id: string, ts?: number | "invalid"): Promise<string> {
34+
const dir = path.join(base, "tasks", id)
35+
await fs.mkdir(dir, { recursive: true })
36+
const metadataPath = path.join(dir, GlobalFileNames.taskMetadata)
37+
const metadata = ts === "invalid" ? "{ invalid json" : JSON.stringify({ ts: ts ?? Date.now() }, null, 2)
38+
await fs.writeFile(metadataPath, metadata, "utf8")
39+
return dir
40+
}
41+
42+
describe("utils/task-history-retention.ts purgeOldTasks()", () => {
43+
it("purges tasks older than 7 days when retention is '7'", async () => {
44+
const base = await mkTempBase()
45+
try {
46+
const now = Date.now()
47+
const days = (n: number) => n * 24 * 60 * 60 * 1000
48+
49+
const old = await createTask(base, "task-8d", now - days(8))
50+
const recent = await createTask(base, "task-6d", now - days(6))
51+
52+
const { purgedCount } = await purgeOldTasks("7", base, () => {}, false)
53+
expect(purgedCount).toBe(1)
54+
expect(await exists(old)).toBe(false)
55+
expect(await exists(recent)).toBe(true)
56+
} finally {
57+
await fs.rm(base, { recursive: true, force: true })
58+
}
59+
})
60+
61+
it("purges tasks older than 3 days when retention is '3'", async () => {
62+
const base = await mkTempBase()
63+
try {
64+
const now = Date.now()
65+
const days = (n: number) => n * 24 * 60 * 60 * 1000
66+
67+
const old = await createTask(base, "task-4d", now - days(4))
68+
const recent = await createTask(base, "task-2d", now - days(2))
69+
70+
const { purgedCount } = await purgeOldTasks("3", base, () => {}, false)
71+
expect(purgedCount).toBe(1)
72+
expect(await exists(old)).toBe(false)
73+
expect(await exists(recent)).toBe(true)
74+
} finally {
75+
await fs.rm(base, { recursive: true, force: true })
76+
}
77+
})
78+
79+
it("does not delete anything in dry run mode but still reports purgedCount", async () => {
80+
const base = await mkTempBase()
81+
try {
82+
const now = Date.now()
83+
const days = (n: number) => n * 24 * 60 * 60 * 1000
84+
85+
const old = await createTask(base, "task-8d", now - days(8))
86+
const recent = await createTask(base, "task-6d", now - days(6))
87+
88+
const { purgedCount } = await purgeOldTasks("7", base, () => {}, true)
89+
expect(purgedCount).toBe(1)
90+
// In dry run, nothing is deleted
91+
expect(await exists(old)).toBe(true)
92+
expect(await exists(recent)).toBe(true)
93+
} finally {
94+
await fs.rm(base, { recursive: true, force: true })
95+
}
96+
})
97+
98+
it("does nothing when retention is 'never'", async () => {
99+
const base = await mkTempBase()
100+
try {
101+
const now = Date.now()
102+
const oldTs = now - 45 * 24 * 60 * 60 * 1000 // 45 days ago
103+
const t1 = await createTask(base, "task-old", oldTs)
104+
const t2 = await createTask(base, "task-new", now)
105+
106+
const { purgedCount, cutoff } = await purgeOldTasks("never", base, () => {})
107+
108+
expect(purgedCount).toBe(0)
109+
expect(cutoff).toBeNull()
110+
expect(await exists(t1)).toBe(true)
111+
expect(await exists(t2)).toBe(true)
112+
} finally {
113+
await fs.rm(base, { recursive: true, force: true })
114+
}
115+
})
116+
117+
it("purges tasks older than 30 days and keeps newer or invalid-metadata ones", async () => {
118+
const base = await mkTempBase()
119+
try {
120+
const now = Date.now()
121+
const days = (n: number) => n * 24 * 60 * 60 * 1000
122+
123+
// One older than 30 days => delete
124+
const old = await createTask(base, "task-31d", now - days(31))
125+
// One newer than 30 days => keep
126+
const recent = await createTask(base, "task-29d", now - days(29))
127+
// Invalid metadata => skipped (kept)
128+
const invalid = await createTask(base, "task-invalid", "invalid")
129+
130+
const { purgedCount, cutoff } = await purgeOldTasks("30", base, () => {})
131+
132+
expect(typeof cutoff).toBe("number")
133+
expect(purgedCount).toBe(1)
134+
expect(await exists(old)).toBe(false)
135+
expect(await exists(recent)).toBe(true)
136+
expect(await exists(invalid)).toBe(true)
137+
} finally {
138+
await fs.rm(base, { recursive: true, force: true })
139+
}
140+
})
141+
})

src/core/webview/ClineProvider.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1901,6 +1901,7 @@ export class ClineProvider
19011901
historyPreviewCollapsed,
19021902
reasoningBlockCollapsed,
19031903
cloudUserInfo,
1904+
taskHistoryRetention,
19041905
cloudIsAuthenticated,
19051906
sharingEnabled,
19061907
organizationAllowList,
@@ -2080,6 +2081,8 @@ export class ClineProvider
20802081
includeDiagnosticMessages: includeDiagnosticMessages ?? true,
20812082
maxDiagnosticMessages: maxDiagnosticMessages ?? 50,
20822083
includeTaskHistoryInEnhance: includeTaskHistoryInEnhance ?? true,
2084+
// Task history retention setting for About tab dropdown
2085+
taskHistoryRetention: taskHistoryRetention ?? "never",
20832086
includeCurrentTime: includeCurrentTime ?? true,
20842087
includeCurrentCost: includeCurrentCost ?? true,
20852088
taskSyncEnabled,
@@ -2275,6 +2278,8 @@ export class ClineProvider
22752278
organizationSettingsVersion,
22762279
condensingApiConfigId: stateValues.condensingApiConfigId,
22772280
customCondensingPrompt: stateValues.customCondensingPrompt,
2281+
// Task history retention selection
2282+
taskHistoryRetention: stateValues.taskHistoryRetention ?? "never",
22782283
codebaseIndexModels: stateValues.codebaseIndexModels ?? EMBEDDING_MODEL_PROFILES,
22792284
codebaseIndexConfig: {
22802285
codebaseIndexEnabled: stateValues.codebaseIndexConfig?.codebaseIndexEnabled ?? true,

src/core/webview/webviewMessageHandler.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,12 @@ export const webviewMessageHandler = async (
555555
await vscode.workspace
556556
.getConfiguration(Package.name)
557557
.update("deniedCommands", newValue, vscode.ConfigurationTarget.Global)
558+
} else if (key === "taskHistoryRetention") {
559+
const val = ((value ?? "never") as string).toString()
560+
newValue = val
561+
await vscode.workspace
562+
.getConfiguration(Package.name)
563+
.update("taskHistoryRetention", val, vscode.ConfigurationTarget.Global)
558564
} else if (key === "ttsEnabled") {
559565
newValue = value ?? true
560566
setTtsEnabled(newValue as boolean)

src/extension.ts

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as vscode from "vscode"
22
import * as dotenvx from "@dotenvx/dotenvx"
33
import * as path from "path"
4+
import * as fs from "fs/promises"
45

56
// Load environment variables from .env file
67
try {
@@ -41,6 +42,7 @@ import {
4142
} from "./activate"
4243
import { initializeI18n } from "./i18n"
4344
import { flushModels, getModels } from "./api/providers/fetchers/modelCache"
45+
import { purgeOldTasks } from "./utils/task-history-retention"
4446

4547
/**
4648
* Built using https://github.com/microsoft/vscode-webview-ui-toolkit
@@ -100,6 +102,40 @@ export async function activate(context: vscode.ExtensionContext) {
100102

101103
const contextProxy = await ContextProxy.getInstance(context)
102104

105+
// Initialize the provider *before* the Roo Code Cloud service so we can reuse its task deletion logic.
106+
const provider = new ClineProvider(context, outputChannel, "sidebar", contextProxy, mdmService)
107+
108+
// Task history retention purge (runs only on activation)
109+
try {
110+
const config = vscode.workspace.getConfiguration(Package.name)
111+
const retention = config.get<string>("taskHistoryRetention", "never") ?? "never"
112+
113+
outputChannel.appendLine(`[Retention] Startup purge: setting=${retention}`)
114+
115+
const result = await purgeOldTasks(
116+
retention as any,
117+
contextProxy.globalStorageUri.fsPath,
118+
(m) => {
119+
outputChannel.appendLine(m)
120+
console.log(m)
121+
},
122+
false,
123+
async (taskId: string, _taskDirPath: string) => {
124+
// Reuse the same internal deletion logic as the History view so that
125+
// checkpoints, shadow repositories, and task state are cleaned up consistently.
126+
await provider.deleteTaskWithId(taskId)
127+
},
128+
)
129+
130+
outputChannel.appendLine(
131+
`[Retention] Startup purge complete: purged=${result.purgedCount}, cutoff=${result.cutoff ?? "none"}`,
132+
)
133+
} catch (error) {
134+
outputChannel.appendLine(
135+
`[Retention] Failed during startup purge: ${error instanceof Error ? error.message : String(error)}`,
136+
)
137+
}
138+
103139
// Initialize code index managers for all workspace folders.
104140
const codeIndexManagers: CodeIndexManager[] = []
105141

@@ -123,9 +159,6 @@ export async function activate(context: vscode.ExtensionContext) {
123159
}
124160
}
125161

126-
// Initialize the provider *before* the Roo Code Cloud service.
127-
const provider = new ClineProvider(context, outputChannel, "sidebar", contextProxy, mdmService)
128-
129162
// Initialize Roo Code Cloud service.
130163
const postStateListener = () => ClineProvider.getVisibleInstance()?.postStateToWebview()
131164

src/package.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,19 @@
436436
"minimum": 1,
437437
"maximum": 200,
438438
"description": "%settings.codeIndex.embeddingBatchSize.description%"
439+
},
440+
"roo-cline.taskHistoryRetention": {
441+
"type": "string",
442+
"enum": [
443+
"never",
444+
"90",
445+
"60",
446+
"30",
447+
"7",
448+
"3"
449+
],
450+
"default": "never",
451+
"description": "%settings.taskHistoryRetention.description%"
439452
}
440453
}
441454
}

src/package.nls.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,5 +43,6 @@
4343
"settings.useAgentRules.description": "Enable loading of AGENTS.md files for agent-specific rules (see https://agent-rules.org/)",
4444
"settings.apiRequestTimeout.description": "Maximum time in seconds to wait for API responses (0 = no timeout, 1-3600s, default: 600s). Higher values are recommended for local providers like LM Studio and Ollama that may need more processing time.",
4545
"settings.newTaskRequireTodos.description": "Require todos parameter when creating new tasks with the new_task tool",
46-
"settings.codeIndex.embeddingBatchSize.description": "The batch size for embedding operations during code indexing. Adjust this based on your API provider's limits. Default is 60."
46+
"settings.codeIndex.embeddingBatchSize.description": "The batch size for embedding operations during code indexing. Adjust this based on your API provider's limits. Default is 60.",
47+
"settings.taskHistoryRetention.description": "Auto-delete task history on extension reload. Deletes tasks older than the selected period. Options: Never (default), 90 days, 60 days, 30 days, 7 days, or 3 days. Warning: This cannot be undone and only runs on plugin reload."
4748
}

src/shared/ExtensionMessage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,7 @@ export type ExtensionState = Pick<
282282
| "reasoningBlockCollapsed"
283283
| "includeCurrentTime"
284284
| "includeCurrentCost"
285+
| "taskHistoryRetention"
285286
> & {
286287
version: string
287288
clineMessages: ClineMessage[]

0 commit comments

Comments
 (0)