Skip to content

Commit 0a19fb5

Browse files
authored
Merge pull request RooCodeInc#1429 from RooVetGit/cte/resume-task-checkpoints-config
Choose the correct checkpoint storage strategy when resuming tasks
2 parents 70a88ae + f685932 commit 0a19fb5

File tree

4 files changed

+257
-21
lines changed

4 files changed

+257
-21
lines changed

src/core/webview/ClineProvider.ts

Lines changed: 41 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,15 @@ import pWaitFor from "p-wait-for"
77
import * as path from "path"
88
import * as vscode from "vscode"
99
import simpleGit from "simple-git"
10-
import { setPanel } from "../../activate/registerCommands"
1110

11+
import { setPanel } from "../../activate/registerCommands"
1212
import { ApiConfiguration, ApiProvider, ModelInfo, API_CONFIG_KEYS } from "../../shared/api"
1313
import { findLast } from "../../shared/array"
1414
import { supportPrompt } from "../../shared/support-prompt"
1515
import { GlobalFileNames } from "../../shared/globalFileNames"
1616
import { SecretKey, GlobalStateKey, SECRET_KEYS, GLOBAL_STATE_KEYS } from "../../shared/globalState"
1717
import { HistoryItem } from "../../shared/HistoryItem"
18+
import { CheckpointStorage } from "../../shared/checkpoints"
1819
import { ApiConfigMeta, ExtensionMessage } from "../../shared/ExtensionMessage"
1920
import { checkoutDiffPayloadSchema, checkoutRestorePayloadSchema, WebviewMessage } from "../../shared/WebviewMessage"
2021
import { Mode, PromptComponent, defaultModeSlug, ModeConfig } from "../../shared/modes"
@@ -28,6 +29,7 @@ import { getTheme } from "../../integrations/theme/getTheme"
2829
import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker"
2930
import { McpHub } from "../../services/mcp/McpHub"
3031
import { McpServerManager } from "../../services/mcp/McpServerManager"
32+
import { ShadowCheckpointService } from "../../services/checkpoints/ShadowCheckpointService"
3133
import { fileExistsAtPath } from "../../utils/fs"
3234
import { playSound, setSoundEnabled, setSoundVolume } from "../../utils/sound"
3335
import { singleCompletionHandler } from "../../utils/single-completion-handler"
@@ -47,7 +49,7 @@ import { getOllamaModels } from "../../api/providers/ollama"
4749
import { getVsCodeLmModels } from "../../api/providers/vscode-lm"
4850
import { getLmStudioModels } from "../../api/providers/lmstudio"
4951
import { ACTION_NAMES } from "../CodeActionProvider"
50-
import { Cline } from "../Cline"
52+
import { Cline, ClineOptions } from "../Cline"
5153
import { openMention } from "../mentions"
5254
import { getNonce } from "./getNonce"
5355
import { getUri } from "./getUri"
@@ -525,22 +527,43 @@ export class ClineProvider implements vscode.WebviewViewProvider {
525527
const modePrompt = customModePrompts?.[mode] as PromptComponent
526528
const effectiveInstructions = [globalInstructions, modePrompt?.customInstructions].filter(Boolean).join("\n\n")
527529

528-
// TODO: The `checkpointStorage` value should be derived from the
529-
// task data on disk; the current setting could be different than
530-
// the setting at the time the task was created.
530+
const taskId = historyItem.id
531+
const globalStorageDir = this.contextProxy.globalStorageUri.fsPath
532+
const workspaceDir = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? ""
533+
534+
const checkpoints: Pick<ClineOptions, "enableCheckpoints" | "checkpointStorage"> = {
535+
enableCheckpoints,
536+
checkpointStorage,
537+
}
538+
539+
if (enableCheckpoints) {
540+
try {
541+
checkpoints.checkpointStorage = await ShadowCheckpointService.getTaskStorage({
542+
taskId,
543+
globalStorageDir,
544+
workspaceDir,
545+
})
546+
547+
this.log(
548+
`[ClineProvider#initClineWithHistoryItem] Using ${checkpoints.checkpointStorage} storage for ${taskId}`,
549+
)
550+
} catch (error) {
551+
checkpoints.enableCheckpoints = false
552+
this.log(`[ClineProvider#initClineWithHistoryItem] Error getting task storage: ${error.message}`)
553+
}
554+
}
531555

532556
const newCline = new Cline({
533557
provider: this,
534558
apiConfiguration,
535559
customInstructions: effectiveInstructions,
536560
enableDiff,
537-
enableCheckpoints,
538-
checkpointStorage,
561+
...checkpoints,
539562
fuzzyMatchThreshold,
540563
historyItem,
541564
experiments,
542565
})
543-
// get this cline task number id from the history item and set it to newCline
566+
544567
newCline.setTaskNumber(historyItem.number)
545568
await this.addClineToStack(newCline)
546569
}
@@ -2069,21 +2092,20 @@ export class ClineProvider implements vscode.WebviewViewProvider {
20692092
// delete task from the task history state
20702093
await this.deleteTaskFromState(id)
20712094

2072-
// check if checkpoints are enabled
2073-
const { enableCheckpoints } = await this.getState()
20742095
// get the base directory of the project
20752096
const baseDir = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
20762097

2077-
// Delete checkpoints branch.
2078-
// TODO: Also delete the workspace branch if it exists.
2079-
if (enableCheckpoints && baseDir) {
2080-
const branchSummary = await simpleGit(baseDir)
2081-
.branch(["-D", `roo-code-checkpoints-${id}`])
2082-
.catch(() => undefined)
2098+
// Delete associated shadow repository or branch.
2099+
// TODO: Store `workspaceDir` in the `HistoryItem` object.
2100+
const globalStorageDir = this.contextProxy.globalStorageUri.fsPath
2101+
const workspaceDir = baseDir ?? ""
20832102

2084-
if (branchSummary) {
2085-
console.log(`[deleteTaskWithId${id}] deleted checkpoints branch`)
2086-
}
2103+
try {
2104+
await ShadowCheckpointService.deleteTask({ taskId: id, globalStorageDir, workspaceDir })
2105+
} catch (error) {
2106+
console.error(
2107+
`[deleteTaskWithId${id}] failed to delete associated shadow repository or branch: ${error instanceof Error ? error.message : String(error)}`,
2108+
)
20872109
}
20882110

20892111
// delete the entire task directory including checkpoints and all content

src/services/checkpoints/RepoPerWorkspaceCheckpointService.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import * as path from "path"
2-
import crypto from "crypto"
32

43
import { CheckpointServiceOptions } from "./types"
54
import { ShadowCheckpointService } from "./ShadowCheckpointService"
@@ -64,7 +63,7 @@ export class RepoPerWorkspaceCheckpointService extends ShadowCheckpointService {
6463
}
6564

6665
public static create({ taskId, workspaceDir, shadowDir, log = console.log }: CheckpointServiceOptions) {
67-
const workspaceHash = crypto.createHash("sha256").update(workspaceDir).digest("hex").toString().slice(0, 8)
66+
const workspaceHash = this.hashWorkspaceDir(workspaceDir)
6867

6968
return new RepoPerWorkspaceCheckpointService(
7069
taskId,

src/services/checkpoints/ShadowCheckpointService.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import fs from "fs/promises"
22
import os from "os"
33
import * as path from "path"
4+
import crypto from "crypto"
45
import EventEmitter from "events"
56

67
import simpleGit, { SimpleGit } from "simple-git"
78
import { globby } from "globby"
9+
import pWaitFor from "p-wait-for"
810

911
import { fileExistsAtPath } from "../../utils/fs"
12+
import { CheckpointStorage } from "../../shared/checkpoints"
1013

1114
import { GIT_DISABLED_SUFFIX } from "./constants"
1215
import { CheckpointDiff, CheckpointResult, CheckpointEventMap } from "./types"
@@ -318,4 +321,135 @@ export abstract class ShadowCheckpointService extends EventEmitter {
318321
override once<K extends keyof CheckpointEventMap>(event: K, listener: (data: CheckpointEventMap[K]) => void) {
319322
return super.once(event, listener)
320323
}
324+
325+
/**
326+
* Storage
327+
*/
328+
329+
public static hashWorkspaceDir(workspaceDir: string) {
330+
return crypto.createHash("sha256").update(workspaceDir).digest("hex").toString().slice(0, 8)
331+
}
332+
333+
protected static taskRepoDir({ taskId, globalStorageDir }: { taskId: string; globalStorageDir: string }) {
334+
return path.join(globalStorageDir, "tasks", taskId, "checkpoints")
335+
}
336+
337+
protected static workspaceRepoDir({
338+
globalStorageDir,
339+
workspaceDir,
340+
}: {
341+
globalStorageDir: string
342+
workspaceDir: string
343+
}) {
344+
return path.join(globalStorageDir, "checkpoints", this.hashWorkspaceDir(workspaceDir))
345+
}
346+
347+
public static async getTaskStorage({
348+
taskId,
349+
globalStorageDir,
350+
workspaceDir,
351+
}: {
352+
taskId: string
353+
globalStorageDir: string
354+
workspaceDir: string
355+
}): Promise<CheckpointStorage | undefined> {
356+
// Is there a checkpoints repo in the task directory?
357+
const taskRepoDir = this.taskRepoDir({ taskId, globalStorageDir })
358+
359+
if (await fileExistsAtPath(taskRepoDir)) {
360+
return "task"
361+
}
362+
363+
// Does the workspace checkpoints repo have a branch for this task?
364+
const workspaceRepoDir = this.workspaceRepoDir({ globalStorageDir, workspaceDir })
365+
366+
if (!(await fileExistsAtPath(workspaceRepoDir))) {
367+
return undefined
368+
}
369+
370+
const git = simpleGit(workspaceRepoDir)
371+
const branches = await git.branchLocal()
372+
373+
if (branches.all.includes(`roo-${taskId}`)) {
374+
return "workspace"
375+
}
376+
377+
return undefined
378+
}
379+
380+
public static async deleteTask({
381+
taskId,
382+
globalStorageDir,
383+
workspaceDir,
384+
}: {
385+
taskId: string
386+
globalStorageDir: string
387+
workspaceDir: string
388+
}) {
389+
const storage = await this.getTaskStorage({ taskId, globalStorageDir, workspaceDir })
390+
391+
if (storage === "task") {
392+
const taskRepoDir = this.taskRepoDir({ taskId, globalStorageDir })
393+
await fs.rm(taskRepoDir, { recursive: true, force: true })
394+
console.log(`[${this.name}#deleteTask.${taskId}] removed ${taskRepoDir}`)
395+
} else if (storage === "workspace") {
396+
const workspaceRepoDir = this.workspaceRepoDir({ globalStorageDir, workspaceDir })
397+
const branchName = `roo-${taskId}`
398+
const git = simpleGit(workspaceRepoDir)
399+
const success = await this.deleteBranch(git, branchName)
400+
401+
if (success) {
402+
console.log(`[${this.name}#deleteTask.${taskId}] deleted branch ${branchName}`)
403+
} else {
404+
console.error(`[${this.name}#deleteTask.${taskId}] failed to delete branch ${branchName}`)
405+
}
406+
}
407+
}
408+
409+
public static async deleteBranch(git: SimpleGit, branchName: string) {
410+
const branches = await git.branchLocal()
411+
412+
if (!branches.all.includes(branchName)) {
413+
console.error(`[${this.constructor.name}#deleteBranch] branch ${branchName} does not exist`)
414+
return false
415+
}
416+
417+
const currentBranch = await git.revparse(["--abbrev-ref", "HEAD"])
418+
419+
if (currentBranch === branchName) {
420+
const worktree = await git.getConfig("core.worktree")
421+
422+
try {
423+
await git.raw(["config", "--unset", "core.worktree"])
424+
await git.reset(["--hard"])
425+
await git.clean("f", ["-d"])
426+
const defaultBranch = branches.all.includes("main") ? "main" : "master"
427+
await git.checkout([defaultBranch, "--force"])
428+
429+
await pWaitFor(
430+
async () => {
431+
const newBranch = await git.revparse(["--abbrev-ref", "HEAD"])
432+
return newBranch === defaultBranch
433+
},
434+
{ interval: 500, timeout: 2_000 },
435+
)
436+
437+
await git.branch(["-D", branchName])
438+
return true
439+
} catch (error) {
440+
console.error(
441+
`[${this.constructor.name}#deleteBranch] failed to delete branch ${branchName}: ${error instanceof Error ? error.message : String(error)}`,
442+
)
443+
444+
return false
445+
} finally {
446+
if (worktree.value) {
447+
await git.addConfig("core.worktree", worktree.value)
448+
}
449+
}
450+
} else {
451+
await git.branch(["-D", branchName])
452+
return true
453+
}
454+
}
321455
}

src/services/checkpoints/__tests__/ShadowCheckpointService.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { EventEmitter } from "events"
88
import { simpleGit, SimpleGit } from "simple-git"
99

1010
import { fileExistsAtPath } from "../../../utils/fs"
11+
12+
import { ShadowCheckpointService } from "../ShadowCheckpointService"
1113
import { RepoPerTaskCheckpointService } from "../RepoPerTaskCheckpointService"
1214
import { RepoPerWorkspaceCheckpointService } from "../RepoPerWorkspaceCheckpointService"
1315

@@ -648,3 +650,82 @@ describe.each([
648650
})
649651
})
650652
})
653+
654+
describe("ShadowCheckpointService", () => {
655+
const taskId = "test-task-storage"
656+
const tmpDir = path.join(os.tmpdir(), "CheckpointService")
657+
const globalStorageDir = path.join(tmpDir, "global-storage-dir")
658+
const workspaceDir = path.join(tmpDir, "workspace-dir")
659+
const workspaceHash = ShadowCheckpointService.hashWorkspaceDir(workspaceDir)
660+
661+
beforeEach(async () => {
662+
await fs.mkdir(globalStorageDir, { recursive: true })
663+
await fs.mkdir(workspaceDir, { recursive: true })
664+
})
665+
666+
afterEach(async () => {
667+
await fs.rm(globalStorageDir, { recursive: true, force: true })
668+
await fs.rm(workspaceDir, { recursive: true, force: true })
669+
})
670+
671+
describe("getTaskStorage", () => {
672+
it("returns 'task' when task repo exists", async () => {
673+
const service = RepoPerTaskCheckpointService.create({
674+
taskId,
675+
shadowDir: globalStorageDir,
676+
workspaceDir,
677+
log: () => {},
678+
})
679+
680+
await service.initShadowGit()
681+
682+
const storage = await ShadowCheckpointService.getTaskStorage({ taskId, globalStorageDir, workspaceDir })
683+
expect(storage).toBe("task")
684+
})
685+
686+
it("returns 'workspace' when workspace repo exists with task branch", async () => {
687+
const service = RepoPerWorkspaceCheckpointService.create({
688+
taskId,
689+
shadowDir: globalStorageDir,
690+
workspaceDir,
691+
log: () => {},
692+
})
693+
694+
await service.initShadowGit()
695+
696+
const storage = await ShadowCheckpointService.getTaskStorage({ taskId, globalStorageDir, workspaceDir })
697+
expect(storage).toBe("workspace")
698+
})
699+
700+
it("returns undefined when no repos exist", async () => {
701+
const storage = await ShadowCheckpointService.getTaskStorage({ taskId, globalStorageDir, workspaceDir })
702+
expect(storage).toBeUndefined()
703+
})
704+
705+
it("returns undefined when workspace repo exists but has no task branch", async () => {
706+
// Setup: Create workspace repo without the task branch
707+
const workspaceRepoDir = path.join(globalStorageDir, "checkpoints", workspaceHash)
708+
await fs.mkdir(workspaceRepoDir, { recursive: true })
709+
710+
// Create git repo without adding the specific branch
711+
const git = simpleGit(workspaceRepoDir)
712+
await git.init()
713+
await git.addConfig("user.name", "Roo Code")
714+
await git.addConfig("user.email", "[email protected]")
715+
716+
// We need to create a commit, but we won't create the specific branch
717+
const testFile = path.join(workspaceRepoDir, "test.txt")
718+
await fs.writeFile(testFile, "Test content")
719+
await git.add(".")
720+
await git.commit("Initial commit")
721+
722+
const storage = await ShadowCheckpointService.getTaskStorage({
723+
taskId,
724+
globalStorageDir,
725+
workspaceDir,
726+
})
727+
728+
expect(storage).toBeUndefined()
729+
})
730+
})
731+
})

0 commit comments

Comments
 (0)