Skip to content

Commit 0bd56ad

Browse files
committed
Checkpoints: one-branch-per-task / one-repo-per-workspace
1 parent 3168b1e commit 0bd56ad

File tree

11 files changed

+407
-236
lines changed

11 files changed

+407
-236
lines changed

src/core/Cline.ts

Lines changed: 112 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Anthropic } from "@anthropic-ai/sdk"
22
import cloneDeep from "clone-deep"
3-
import { DiffStrategy, getDiffStrategy, UnifiedDiffStrategy } from "./diff/DiffStrategy"
3+
import { DiffStrategy, getDiffStrategy } from "./diff/DiffStrategy"
44
import { validateToolUse, isToolAllowedForMode, ToolName } from "./mode-validator"
55
import delay from "delay"
66
import fs from "fs/promises"
@@ -13,7 +13,11 @@ import * as vscode from "vscode"
1313
import { ApiHandler, buildApiHandler } from "../api"
1414
import { ApiStream } from "../api/transform/stream"
1515
import { DIFF_VIEW_URI_SCHEME, DiffViewProvider } from "../integrations/editor/DiffViewProvider"
16-
import { ShadowCheckpointService } from "../services/checkpoints/ShadowCheckpointService"
16+
import {
17+
CheckpointServiceOptions,
18+
RepoPerTaskCheckpointService,
19+
RepoPerWorkspaceCheckpointService,
20+
} from "../services/checkpoints"
1721
import { findToolName, formatContentBlockToMarkdown } from "../integrations/misc/export-markdown"
1822
import {
1923
extractTextFromFile,
@@ -77,6 +81,7 @@ export type ClineOptions = {
7781
customInstructions?: string
7882
enableDiff?: boolean
7983
enableCheckpoints?: boolean
84+
checkpointStorage?: "task" | "workspace"
8085
fuzzyMatchThreshold?: number
8186
task?: string
8287
images?: string[]
@@ -115,8 +120,9 @@ export class Cline {
115120
isInitialized = false
116121

117122
// checkpoints
118-
enableCheckpoints: boolean = false
119-
private checkpointService?: ShadowCheckpointService
123+
private enableCheckpoints: boolean
124+
private checkpointStorage: "task" | "workspace"
125+
private checkpointService?: RepoPerTaskCheckpointService | RepoPerWorkspaceCheckpointService
120126

121127
// streaming
122128
isWaitingForFirstChunk = false
@@ -136,7 +142,8 @@ export class Cline {
136142
apiConfiguration,
137143
customInstructions,
138144
enableDiff,
139-
enableCheckpoints,
145+
enableCheckpoints = false,
146+
checkpointStorage = "task",
140147
fuzzyMatchThreshold,
141148
task,
142149
images,
@@ -160,7 +167,8 @@ export class Cline {
160167
this.fuzzyMatchThreshold = fuzzyMatchThreshold ?? 1.0
161168
this.providerRef = new WeakRef(provider)
162169
this.diffViewProvider = new DiffViewProvider(cwd)
163-
this.enableCheckpoints = enableCheckpoints ?? false
170+
this.enableCheckpoints = enableCheckpoints
171+
this.checkpointStorage = checkpointStorage
164172

165173
// Initialize diffStrategy based on current state
166174
this.updateDiffStrategy(Experiments.isEnabled(experiments ?? {}, EXPERIMENT_IDS.DIFF_STRATEGY))
@@ -747,7 +755,8 @@ export class Cline {
747755
}
748756

749757
private async initiateTaskLoop(userContent: UserContent): Promise<void> {
750-
this.initializeCheckpoints()
758+
// Kicks off the checkpoints initialization process in the background.
759+
this.getCheckpointService()
751760

752761
let nextUserContent = userContent
753762
let includeFileDetails = true
@@ -3352,9 +3361,13 @@ export class Cline {
33523361

33533362
// Checkpoints
33543363

3355-
private async initializeCheckpoints() {
3364+
private getCheckpointService() {
33563365
if (!this.enableCheckpoints) {
3357-
return
3366+
return undefined
3367+
}
3368+
3369+
if (this.checkpointService) {
3370+
return this.checkpointService
33583371
}
33593372

33603373
const log = (message: string) => {
@@ -3368,47 +3381,45 @@ export class Cline {
33683381
}
33693382

33703383
try {
3371-
if (this.checkpointService) {
3372-
log("[Cline#initializeCheckpoints] checkpointService already initialized")
3373-
return
3374-
}
3375-
33763384
const workspaceDir = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
33773385

33783386
if (!workspaceDir) {
33793387
log("[Cline#initializeCheckpoints] workspace folder not found, disabling checkpoints")
33803388
this.enableCheckpoints = false
3381-
return
3389+
return undefined
33823390
}
33833391

3384-
const shadowDir = this.providerRef.deref()?.context.globalStorageUri.fsPath
3392+
const globalStorageDir = this.providerRef.deref()?.context.globalStorageUri.fsPath
33853393

3386-
if (!shadowDir) {
3387-
log("[Cline#initializeCheckpoints] shadowDir not found, disabling checkpoints")
3394+
if (!globalStorageDir) {
3395+
log("[Cline#initializeCheckpoints] globalStorageDir not found, disabling checkpoints")
33883396
this.enableCheckpoints = false
3389-
return
3397+
return undefined
33903398
}
33913399

3392-
const service = await ShadowCheckpointService.create({ taskId: this.taskId, workspaceDir, shadowDir, log })
3393-
3394-
if (!service) {
3395-
log("[Cline#initializeCheckpoints] failed to create checkpoint service, disabling checkpoints")
3396-
this.enableCheckpoints = false
3397-
return
3400+
const options: CheckpointServiceOptions = {
3401+
taskId: this.taskId,
3402+
workspaceDir,
3403+
shadowDir: globalStorageDir,
3404+
log,
33983405
}
33993406

3400-
service.on("initialize", ({ workspaceDir, created, duration }) => {
3407+
const service =
3408+
this.checkpointStorage === "task"
3409+
? RepoPerTaskCheckpointService.create(options)
3410+
: RepoPerWorkspaceCheckpointService.create(options)
3411+
3412+
service.on("initialize", () => {
34013413
try {
3402-
if (created) {
3403-
log(`[Cline#initializeCheckpoints] created new shadow repo (${workspaceDir}) in ${duration}ms`)
3404-
} else {
3405-
log(
3406-
`[Cline#initializeCheckpoints] found existing shadow repo (${workspaceDir}) in ${duration}ms`,
3407-
)
3408-
}
3414+
const isCheckpointNeeded =
3415+
typeof this.clineMessages.find(({ say }) => say === "checkpoint_saved") === "undefined"
34093416

34103417
this.checkpointService = service
3411-
this.checkpointSave()
3418+
3419+
if (isCheckpointNeeded) {
3420+
log("[Cline#initializeCheckpoints] no checkpoints found, saving initial checkpoint")
3421+
this.checkpointSave()
3422+
}
34123423
} catch (err) {
34133424
log("[Cline#initializeCheckpoints] caught error in on('initialize'), disabling checkpoints")
34143425
this.enableCheckpoints = false
@@ -3417,41 +3428,77 @@ export class Cline {
34173428

34183429
service.on("checkpoint", ({ isFirst, fromHash: from, toHash: to }) => {
34193430
try {
3420-
log(`[Cline#initializeCheckpoints] ${isFirst ? "initial" : "incremental"} checkpoint saved: ${to}`)
34213431
this.providerRef.deref()?.postMessageToWebview({ type: "currentCheckpointUpdated", text: to })
34223432

3423-
this.say("checkpoint_saved", to, undefined, undefined, { isFirst, from, to }).catch((e) =>
3424-
console.error("Error saving checkpoint message:", e),
3425-
)
3433+
this.say("checkpoint_saved", to, undefined, undefined, { isFirst, from, to }).catch((err) => {
3434+
log("[Cline#initializeCheckpoints] caught unexpected error in say('checkpoint_saved')")
3435+
console.error(err)
3436+
})
34263437
} catch (err) {
3427-
log("[Cline#initializeCheckpoints] caught error in on('checkpoint'), disabling checkpoints")
3438+
log(
3439+
"[Cline#initializeCheckpoints] caught unexpected error in on('checkpoint'), disabling checkpoints",
3440+
)
3441+
console.error(err)
34283442
this.enableCheckpoints = false
34293443
}
34303444
})
34313445

3432-
service.initShadowGit()
3446+
service.initShadowGit().catch((err) => {
3447+
log("[Cline#initializeCheckpoints] caught unexpected error in initShadowGit, disabling checkpoints")
3448+
console.error(err)
3449+
this.enableCheckpoints = false
3450+
})
3451+
3452+
return service
34333453
} catch (err) {
3434-
log("[Cline#initializeCheckpoints] caught error in initializeCheckpoints(), disabling checkpoints")
3454+
log("[Cline#initializeCheckpoints] caught unexpected error, disabling checkpoints")
34353455
this.enableCheckpoints = false
3456+
return undefined
3457+
}
3458+
}
3459+
3460+
private async getInitializedCheckpointService({
3461+
interval = 250,
3462+
timeout = 15_000,
3463+
}: { interval?: number; timeout?: number } = {}) {
3464+
const service = this.getCheckpointService()
3465+
3466+
if (!service || service.isInitialized) {
3467+
return service
3468+
}
3469+
3470+
try {
3471+
await pWaitFor(
3472+
() => {
3473+
console.log("[Cline#getCheckpointService] waiting for service to initialize")
3474+
return service.isInitialized
3475+
},
3476+
{ interval, timeout },
3477+
)
3478+
return service
3479+
} catch (err) {
3480+
return undefined
34363481
}
34373482
}
34383483

34393484
public async checkpointDiff({
34403485
ts,
3486+
previousCommitHash,
34413487
commitHash,
34423488
mode,
34433489
}: {
34443490
ts: number
3491+
previousCommitHash?: string
34453492
commitHash: string
34463493
mode: "full" | "checkpoint"
34473494
}) {
3448-
if (!this.checkpointService || !this.enableCheckpoints) {
3495+
const service = await this.getInitializedCheckpointService()
3496+
3497+
if (!service) {
34493498
return
34503499
}
34513500

3452-
let previousCommitHash = undefined
3453-
3454-
if (mode === "checkpoint") {
3501+
if (!previousCommitHash && mode === "checkpoint") {
34553502
const previousCheckpoint = this.clineMessages
34563503
.filter(({ say }) => say === "checkpoint_saved")
34573504
.sort((a, b) => b.ts - a.ts)
@@ -3461,7 +3508,7 @@ export class Cline {
34613508
}
34623509

34633510
try {
3464-
const changes = await this.checkpointService.getDiff({ from: previousCommitHash, to: commitHash })
3511+
const changes = await service.getDiff({ from: previousCommitHash, to: commitHash })
34653512

34663513
if (!changes?.length) {
34673514
vscode.window.showInformationMessage("No changes found.")
@@ -3488,12 +3535,25 @@ export class Cline {
34883535
}
34893536

34903537
public checkpointSave() {
3491-
if (!this.checkpointService || !this.enableCheckpoints) {
3538+
const service = this.getCheckpointService()
3539+
3540+
if (!service) {
3541+
return
3542+
}
3543+
3544+
if (!service.isInitialized) {
3545+
this.providerRef
3546+
.deref()
3547+
?.log("[checkpointSave] checkpoints didn't initialize in time, disabling checkpoints for this task")
3548+
this.enableCheckpoints = false
34923549
return
34933550
}
34943551

34953552
// Start the checkpoint process in the background.
3496-
this.checkpointService.saveCheckpoint(`Task: ${this.taskId}, Time: ${Date.now()}`)
3553+
service.saveCheckpoint(`Task: ${this.taskId}, Time: ${Date.now()}`).catch((err) => {
3554+
console.error("[Cline#checkpointSave] caught unexpected error, disabling checkpoints", err)
3555+
this.enableCheckpoints = false
3556+
})
34973557
}
34983558

34993559
public async checkpointRestore({
@@ -3505,7 +3565,9 @@ export class Cline {
35053565
commitHash: string
35063566
mode: "preview" | "restore"
35073567
}) {
3508-
if (!this.checkpointService || !this.enableCheckpoints) {
3568+
const service = await this.getInitializedCheckpointService()
3569+
3570+
if (!service) {
35093571
return
35103572
}
35113573

@@ -3516,7 +3578,7 @@ export class Cline {
35163578
}
35173579

35183580
try {
3519-
await this.checkpointService.restoreCheckpoint(commitHash)
3581+
await service.restoreCheckpoint(commitHash)
35203582

35213583
await this.providerRef.deref()?.postMessageToWebview({ type: "currentCheckpointUpdated", text: commitHash })
35223584

src/core/webview/ClineProvider.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -887,6 +887,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
887887

888888
if (result.success) {
889889
await this.cline?.checkpointDiff(result.data)
890+
} else {
891+
vscode.window.showErrorMessage("Invalid checkpoint diff payload.")
890892
}
891893

892894
break
@@ -907,6 +909,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
907909
} catch (error) {
908910
vscode.window.showErrorMessage("Failed to restore checkpoint.")
909911
}
912+
} else {
913+
vscode.window.showErrorMessage("Invalid checkpoint restore payload.")
910914
}
911915

912916
break
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import * as path from "path"
2+
3+
import { CheckpointServiceOptions } from "./types"
4+
import { ShadowCheckpointService } from "./ShadowCheckpointService"
5+
6+
export class RepoPerTaskCheckpointService extends ShadowCheckpointService {
7+
public static create({ taskId, workspaceDir, shadowDir, log = console.log }: CheckpointServiceOptions) {
8+
return new RepoPerTaskCheckpointService(
9+
taskId,
10+
path.join(shadowDir, "tasks", taskId, "checkpoints"),
11+
workspaceDir,
12+
log,
13+
)
14+
}
15+
}

0 commit comments

Comments
 (0)