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

Commit 19efb33

Browse files
committed
feat: add automatic checkpoint culling for inactive tasks
- Add purgeOldCheckpoints() function with hardcoded 30-day threshold - Add startBackgroundCheckpointPurge() for fire-and-forget activation - Culls only checkpoints/ subdirectory, preserves task history - Runs silently on extension activation (no user notification) - Coexists with existing taskHistoryRetention setting - Add 6 new tests for checkpoint culling functionality
1 parent b5e3a3d commit 19efb33

3 files changed

Lines changed: 389 additions & 2 deletions

File tree

src/__tests__/task-history-retention.spec.ts

Lines changed: 170 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ vi.mock("../utils/storage", () => ({
1010
getStorageBasePath: (p: string) => Promise.resolve(p),
1111
}))
1212

13-
import { purgeOldTasks } from "../utils/task-history-retention"
13+
import { purgeOldTasks, purgeOldCheckpoints } from "../utils/task-history-retention"
1414
import { GlobalFileNames } from "../shared/globalFileNames"
1515

1616
// Helpers
@@ -263,3 +263,172 @@ describe("utils/task-history-retention.ts purgeOldTasks()", () => {
263263
}
264264
})
265265
})
266+
267+
// Helper to create task with checkpoints
268+
async function createTaskWithCheckpoints(
269+
base: string,
270+
id: string,
271+
ts: number,
272+
): Promise<{ taskDir: string; checkpointsDir: string }> {
273+
const taskDir = path.join(base, "tasks", id)
274+
await fs.mkdir(taskDir, { recursive: true })
275+
const metadataPath = path.join(taskDir, GlobalFileNames.taskMetadata)
276+
const metadata = JSON.stringify({ ts }, null, 2)
277+
await fs.writeFile(metadataPath, metadata, "utf8")
278+
const checkpointsDir = path.join(taskDir, "checkpoints")
279+
await fs.mkdir(checkpointsDir, { recursive: true })
280+
// Add some checkpoint content
281+
await fs.writeFile(path.join(checkpointsDir, "checkpoint-1.json"), "{}", "utf8")
282+
return { taskDir, checkpointsDir }
283+
}
284+
285+
describe("utils/task-history-retention.ts purgeOldCheckpoints()", () => {
286+
it("culls checkpoints from tasks older than 30 days", async () => {
287+
const base = await mkTempBase()
288+
try {
289+
const now = Date.now()
290+
const days = (n: number) => n * 24 * 60 * 60 * 1000
291+
292+
// Old task (31 days) - checkpoints should be culled
293+
const old = await createTaskWithCheckpoints(base, "task-old", now - days(31))
294+
// Recent task (29 days) - checkpoints should be kept
295+
const recent = await createTaskWithCheckpoints(base, "task-recent", now - days(29))
296+
297+
const { culledCount } = await purgeOldCheckpoints(base, () => {}, false)
298+
299+
expect(culledCount).toBe(1)
300+
// Old task checkpoints should be removed, but task dir should remain
301+
expect(await exists(old.taskDir)).toBe(true)
302+
expect(await exists(old.checkpointsDir)).toBe(false)
303+
// Recent task should be completely intact
304+
expect(await exists(recent.taskDir)).toBe(true)
305+
expect(await exists(recent.checkpointsDir)).toBe(true)
306+
} finally {
307+
await fs.rm(base, { recursive: true, force: true })
308+
}
309+
})
310+
311+
it("does not delete checkpoints in dry run mode but reports count", async () => {
312+
const base = await mkTempBase()
313+
try {
314+
const now = Date.now()
315+
const days = (n: number) => n * 24 * 60 * 60 * 1000
316+
317+
const old = await createTaskWithCheckpoints(base, "task-old", now - days(31))
318+
319+
const { culledCount } = await purgeOldCheckpoints(base, () => {}, true)
320+
321+
expect(culledCount).toBe(1)
322+
// In dry run, checkpoints should still exist
323+
expect(await exists(old.checkpointsDir)).toBe(true)
324+
} finally {
325+
await fs.rm(base, { recursive: true, force: true })
326+
}
327+
})
328+
329+
it("skips tasks without checkpoints directory", async () => {
330+
const base = await mkTempBase()
331+
try {
332+
const now = Date.now()
333+
const days = (n: number) => n * 24 * 60 * 60 * 1000
334+
335+
// Create an old task WITHOUT checkpoints
336+
const taskDir = await createTask(base, "task-no-checkpoints", now - days(31))
337+
338+
const { culledCount } = await purgeOldCheckpoints(base, () => {}, false)
339+
340+
expect(culledCount).toBe(0)
341+
// Task should be completely intact
342+
expect(await exists(taskDir)).toBe(true)
343+
} finally {
344+
await fs.rm(base, { recursive: true, force: true })
345+
}
346+
})
347+
348+
it("uses mtime fallback when no metadata timestamp", async () => {
349+
const base = await mkTempBase()
350+
try {
351+
const now = Date.now()
352+
const days = (n: number) => n * 24 * 60 * 60 * 1000
353+
354+
// Create a task without metadata but with checkpoints
355+
const taskDir = path.join(base, "tasks", "task-no-metadata")
356+
await fs.mkdir(taskDir, { recursive: true })
357+
const checkpointsDir = path.join(taskDir, "checkpoints")
358+
await fs.mkdir(checkpointsDir, { recursive: true })
359+
await fs.writeFile(path.join(checkpointsDir, "checkpoint.json"), "{}", "utf8")
360+
// Set old mtime
361+
const oldTime = new Date(now - days(31))
362+
await fs.utimes(taskDir, oldTime, oldTime)
363+
364+
const { culledCount } = await purgeOldCheckpoints(base, () => {}, false)
365+
366+
expect(culledCount).toBe(1)
367+
// Task dir should remain, checkpoints should be gone
368+
expect(await exists(taskDir)).toBe(true)
369+
expect(await exists(checkpointsDir)).toBe(false)
370+
} finally {
371+
await fs.rm(base, { recursive: true, force: true })
372+
}
373+
})
374+
375+
it("always uses 30-day hardcoded cutoff", async () => {
376+
const base = await mkTempBase()
377+
try {
378+
const now = Date.now()
379+
const days = (n: number) => n * 24 * 60 * 60 * 1000
380+
381+
// Tasks at various ages around the 30-day boundary
382+
const day29 = await createTaskWithCheckpoints(base, "task-29d", now - days(29))
383+
const day30 = await createTaskWithCheckpoints(base, "task-30d", now - days(30))
384+
const day31 = await createTaskWithCheckpoints(base, "task-31d", now - days(31))
385+
386+
const { culledCount, cutoff } = await purgeOldCheckpoints(base, () => {}, false)
387+
388+
// Check cutoff is approximately 30 days ago
389+
const expectedCutoff = now - days(30)
390+
expect(cutoff).toBeGreaterThan(expectedCutoff - 1000) // Allow 1 second margin
391+
expect(cutoff).toBeLessThan(expectedCutoff + 1000)
392+
393+
// 29 day task should keep checkpoints (younger than 30 days)
394+
expect(await exists(day29.checkpointsDir)).toBe(true)
395+
// 30 and 31 day tasks should lose checkpoints (>= 30 days)
396+
expect(await exists(day30.checkpointsDir)).toBe(false)
397+
expect(await exists(day31.checkpointsDir)).toBe(false)
398+
expect(culledCount).toBe(2)
399+
} finally {
400+
await fs.rm(base, { recursive: true, force: true })
401+
}
402+
})
403+
404+
it("preserves task metadata and other files when culling checkpoints", async () => {
405+
const base = await mkTempBase()
406+
try {
407+
const now = Date.now()
408+
const days = (n: number) => n * 24 * 60 * 60 * 1000
409+
410+
// Create task with checkpoints and other content
411+
const taskDir = path.join(base, "tasks", "task-with-content")
412+
await fs.mkdir(taskDir, { recursive: true })
413+
const metadataPath = path.join(taskDir, GlobalFileNames.taskMetadata)
414+
await fs.writeFile(metadataPath, JSON.stringify({ ts: now - days(31) }), "utf8")
415+
const checkpointsDir = path.join(taskDir, "checkpoints")
416+
await fs.mkdir(checkpointsDir, { recursive: true })
417+
await fs.writeFile(path.join(checkpointsDir, "checkpoint.json"), "{}", "utf8")
418+
// Add conversation history
419+
await fs.writeFile(path.join(taskDir, "conversation.json"), "[]", "utf8")
420+
421+
const { culledCount } = await purgeOldCheckpoints(base, () => {}, false)
422+
423+
expect(culledCount).toBe(1)
424+
// Task dir and metadata should remain
425+
expect(await exists(taskDir)).toBe(true)
426+
expect(await exists(metadataPath)).toBe(true)
427+
expect(await exists(path.join(taskDir, "conversation.json"))).toBe(true)
428+
// Only checkpoints should be removed
429+
expect(await exists(checkpointsDir)).toBe(false)
430+
} finally {
431+
await fs.rm(base, { recursive: true, force: true })
432+
}
433+
})
434+
})

src/extension.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ import {
4444
} from "./activate"
4545
import { initializeI18n } from "./i18n"
4646
import { flushModels, initializeModelCacheRefresh, refreshModels } from "./api/providers/fetchers/modelCache"
47-
import { startBackgroundRetentionPurge } from "./utils/task-history-retention"
47+
import { startBackgroundRetentionPurge, startBackgroundCheckpointPurge } from "./utils/task-history-retention"
4848
import { TASK_HISTORY_RETENTION_OPTIONS, type TaskHistoryRetentionSetting } from "@roo-code/types"
4949

5050
/**
@@ -410,6 +410,13 @@ export async function activate(context: vscode.ExtensionContext) {
410410
})
411411
}
412412

413+
// Checkpoint culling (runs in background after activation)
414+
// Automatically removes checkpoints from tasks not touched in 30 days (non-configurable)
415+
startBackgroundCheckpointPurge({
416+
globalStoragePath: contextProxy.globalStorageUri.fsPath,
417+
log: (m) => outputChannel.appendLine(m),
418+
})
419+
413420
// Implements the `RooCodeAPI` interface.
414421
const socketPath = process.env.ROO_CODE_IPC_SOCKET_PATH
415422
const enableLogging = typeof socketPath === "string"

0 commit comments

Comments
 (0)