-
Notifications
You must be signed in to change notification settings - Fork 2.8k
Auto-delete task history on plugin load (retention + checkpoint cleanup) #9262
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
Re-reviewed latest commits on this PR (cc67f71). No new issues found.
Mention @roomote in a comment to request specific changes to this pull request or fix all unresolved issues. |
784a70d to
67c7629
Compare
d9b68d5 to
42d73fe
Compare
…int-only cleanup; quieter logging
- Remove unused fs/promises import in extension.ts - Fix Turkish translation: replace Cyrillic 'д' with Turkish 'd' in süreden - Fix Catalan translation: use consistent 'extensió' terminology
- Import and use RetentionSetting type instead of 'as any' in extension.ts - Use proper type narrowing for metadata parsing instead of 'as any'
…on flow - Replace generic 'number' with explicit RetentionDays union type to prevent accidental misconfiguration - Skip aggressive fs cleanup when deleteTaskById callback successfully removes the directory - This avoids redundant filesystem work and log noise on stubborn paths
84853c9 to
cc67f71
Compare
heyseth
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Executive Summary
This PR introduces an auto-delete task history feature that purges old tasks on extension reload. The implementation is well-architected, type-safe, and thoroughly tested. The code demonstrates solid engineering practices with proper error handling, i18n support, and integration with existing deletion logic.
Strengths
- ✅Consistency: Excellent reuse of existing
deleteTaskWithId()logic ensures checkpoints, shadow repos, and state are cleaned up alongside files. - ✅Reliability: Sequential deletion prevents race conditions, and robust retry logic handles stubborn filesystem operations.
- ✅Quality: Full i18n support across 17 languages and type-safe schema validation with Zod.
Suggested Enhancements (Code Included)
I have included specific code snippets in the comments to address performance and user trust:
1. Performance: Non-blocking Activation (Critical)
The current implementation awaits the purge process during the activate phase. On machines with large histories or slow disks, this will block the extension startup.
- Fix: I provided a snippet to wrap the logic in a background "fire-and-forget" task (
void (async ...)), ensuring the extension activates immediately. - Optimization: Added an early return check for
retention === "never"to skip the logic entirely for the majority of users.
2. UX: Visibility & Trust
Silent deletion of user data can be alarming if a user expects to find a task.
- Fix: I added a Toast Notification logic to the background task. It only triggers if files were actually deleted and provides a direct link to Settings.
3. Testing: Edge Case Coverage
Since we are permanently deleting files, I identified a gap in testing regarding "Orphan Checkpoints" and "Legacy Mtime Fallback."
- Fix: I drafted 4 specific test cases to verify we strictly target garbage data and do not accidentally delete recent, valid tasks that lack metadata.
Conclusion
This is a high-value maintenance feature. Once the activation blocking issue is resolved (using the background task pattern I suggested), this logic is solid and ready for production!
|
|
||
| // Task history retention purge (runs only on activation) | ||
| try { | ||
| const config = vscode.workspace.getConfiguration(Package.name) | ||
| const retention = config.get<string>("taskHistoryRetention", "never") ?? "never" | ||
|
|
||
| outputChannel.appendLine(`[Retention] Startup purge: setting=${retention}`) | ||
|
|
||
| const result = await purgeOldTasks( | ||
| retention as RetentionSetting, | ||
| contextProxy.globalStorageUri.fsPath, | ||
| (m) => { | ||
| outputChannel.appendLine(m) | ||
| console.log(m) | ||
| }, | ||
| false, | ||
| async (taskId: string, _taskDirPath: string) => { | ||
| // Reuse the same internal deletion logic as the History view so that | ||
| // checkpoints, shadow repositories, and task state are cleaned up consistently. | ||
| await provider.deleteTaskWithId(taskId) | ||
| }, | ||
| ) | ||
|
|
||
| outputChannel.appendLine( | ||
| `[Retention] Startup purge complete: purged=${result.purgedCount}, cutoff=${result.cutoff ?? "none"}`, | ||
| ) | ||
| } catch (error) { | ||
| outputChannel.appendLine( | ||
| `[Retention] Failed during startup purge: ${error instanceof Error ? error.message : String(error)}`, | ||
| ) | ||
| } | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Critical Performance Optimization: Non-blocking Activation
Currently, await purgeOldTasks() blocks the extension activation. If a user has a massive history or a slow disk, this could hang their extension startup.
I strongly recommend moving this to a background "fire-and-forget" execution. By removing the await and handling errors internally, we allow VS Code to finish activating immediately while the cleanup happens asynchronously.
| // Task history retention purge (runs only on activation) | |
| try { | |
| const config = vscode.workspace.getConfiguration(Package.name) | |
| const retention = config.get<string>("taskHistoryRetention", "never") ?? "never" | |
| outputChannel.appendLine(`[Retention] Startup purge: setting=${retention}`) | |
| const result = await purgeOldTasks( | |
| retention as RetentionSetting, | |
| contextProxy.globalStorageUri.fsPath, | |
| (m) => { | |
| outputChannel.appendLine(m) | |
| console.log(m) | |
| }, | |
| false, | |
| async (taskId: string, _taskDirPath: string) => { | |
| // Reuse the same internal deletion logic as the History view so that | |
| // checkpoints, shadow repositories, and task state are cleaned up consistently. | |
| await provider.deleteTaskWithId(taskId) | |
| }, | |
| ) | |
| outputChannel.appendLine( | |
| `[Retention] Startup purge complete: purged=${result.purgedCount}, cutoff=${result.cutoff ?? "none"}`, | |
| ) | |
| } catch (error) { | |
| outputChannel.appendLine( | |
| `[Retention] Failed during startup purge: ${error instanceof Error ? error.message : String(error)}`, | |
| ) | |
| } |
Implementation Note: GitHub collapsed the lines at the end of the activate function, so I cannot make this an inline suggestion. Please insert the following logic manually at the very end of activate, immediately after the activationCompleted command.
// Task history retention purge (runs in background after activation)
// By this point, provider is fully initialized and ready to handle deletions
void (async () => {
try {
const config = vscode.workspace.getConfiguration(Package.name)
const retention = config.get<string>("taskHistoryRetention", "never") ?? "never"
// Skip if retention is disabled
if (retention === "never") {
outputChannel.appendLine("[Retention] Background purge skipped: retention is set to 'never'")
return
}
outputChannel.appendLine(`[Retention] Starting background purge: setting=${retention}`)
const result = await purgeOldTasks(
retention as RetentionSetting,
contextProxy.globalStorageUri.fsPath,
(m) => {
outputChannel.appendLine(m)
console.log(m)
},
false,
async (taskId: string, _taskDirPath: string) => {
// Reuse the same internal deletion logic as the History view so that
// checkpoints, shadow repositories, and task state are cleaned up consistently.
await provider.deleteTaskWithId(taskId)
},
)
outputChannel.appendLine(
`[Retention] Background purge complete: purged=${result.purgedCount}, cutoff=${result.cutoff ?? "none"}`,
)
// Show user notification if tasks were deleted
if (result.purgedCount > 0) {
const retentionDays = retention === "never" ? "" : retention
const message = `Roo Code deleted ${result.purgedCount} task${result.purgedCount === 1 ? "" : "s"} older than ${retentionDays} day${retentionDays === "1" ? "" : "s"}`
vscode.window.showInformationMessage(message, "View Settings", "Dismiss").then((action) => {
if (action === "View Settings") {
vscode.commands.executeCommand("workbench.action.openSettings", "roo-cline.taskHistoryRetention")
}
})
}
} catch (error) {
outputChannel.appendLine(
`[Retention] Failed during background purge: ${error instanceof Error ? error.message : String(error)}`,
)
}
})()UX Note: I bundled a toast notification into this snippet. Silent file deletion can be alarming to users (or confusing if they check history and it's gone), so explicit feedback here builds trust. Feel free to omit that specific if block if you prefer a strictly silent background process, but I personally recommend keeping it.
| } finally { | ||
| await fs.rm(base, { recursive: true, force: true }) | ||
| } | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Suggestion: Robust Edge Case Coverage
Since this feature involves permanent file deletion, I wanted to be absolutely sure we cover the "danger zones." I drafted these 4 test cases to verify the logic handles edge cases safely:
- Orphan Cleanup: Ensures we delete directories that only contain checkpoints (garbage).
- Safety Check: Ensures we don't delete directories that look like orphans but actually contain user files.
- Legacy Fallback: Verifies that tasks without metadata still get cleaned up based on file modification time.
- Priority: Confirms that if metadata exists, we trust it over the file system timestamp (crucial for accurate retention).
You should be able to drop these directly into the suite.
| }) | |
| }) | |
| 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 }) | |
| } | |
| }) |
Summary
Closes #8040
Adds an auto-delete task history feature that purges old tasks on extension reload, helping users manage disk space and keep their task history clean.
Changes
New Setting
roo-cline.taskHistoryRetentionPurge Logic (
src/utils/task-history-retention.ts)task_metadata.jsontimestampcheckpoints/subdirectory)ClineProvider.deleteTaskWithId()to ensure consistent cleanup of checkpoints, shadow repositories, and task stateUI Updates
About.tsxsettings panelTesting
task-history-retention.spec.tscovering:Technical Notes
Important
Adds auto-delete task history feature with configurable retention settings and UI support, including logic for purging old tasks and comprehensive testing.
task-history-retention.ts.ClineProvider.deleteTaskWithId()for consistent cleanup.taskHistoryRetentionadded to global configuration inglobal-settings.ts.About.tsxfor retention settings with i18n support.task-history-retention.spec.tsfor retention periods, dry run mode, and invalid metadata handling.package.jsonand i18n files for new setting descriptions.This description was created by
for 84853c9. You can customize this summary. It will automatically update as commits are pushed.