Skip to content

Checkpoints:任务回放的影子 Git

源码版本v4.0.10

职责

Checkpoints 是 Cline 给任务加的「时间机器」。每次 LLM 要动手前,Cline 会把当前工作区拍一张快照存下来;如果用户对某次工具调用的结果不满意,可以把代码、文件甚至整段对话历史回滚到任意一个快照点,然后从那里继续对话。

整套机制建立在影子 git 仓库 (shadow git) 上:不是用户工作区里那个 .git,而是 Cline 在自己的 globalStorage 里偷偷维护的另一个 git 仓库,core.worktree 指向用户的工作目录。每次 commit 就是一个 checkpoint,git reset --hard 就完成回滚。这样既能复用 git 的 diff/stage/commit 能力,又完全不污染用户自己的仓库状态。

对外暴露的核心是 TaskCheckpointManager,它把「该不该建快照、commit hash 该贴到哪条消息上、回滚后要把对话历史删到哪一段」这些业务逻辑包起来;真正干 git 活的是底下的 CheckpointTrackerGitOperations

设计动机

  • 不能改用户的 .git:用户的工作区可能已经在另一个 git 仓库下,直接 git add . 会污染他们的 index。影子 git 用独立的 .git 目录 + core.worktree 指回去,两个仓库完全隔离。
  • 每次工具调用都要可回退:LLM 编辑文件不可预测,用户需要看到「这个工具调用之前」和「之后」的对比,并能撤销到之前任意一步。所以 commit 的节奏必须卡在工具执行边界。
  • 避免把消息历史也一起回滚:回滚分两类——workspace 只动文件,task 只动对话消息,taskAndWorkspace 两者都动。这样用户可以「保留对话但还原代码」,或「连对话一起退回去重走」。
  • 多 root 工作区要分别处理:一个 workspace 可能挂多个目录,单根影子 git 不够用,所以有 MultiRootCheckpointManager 在外面分发。
  • 嵌套 .git 会把 git add 搞挂:git 遇到子目录里的 .git 默认按 submodule 处理,影子 git 必须在每次 add 之前把嵌套 .git 临时改名禁用,add 完再改回来。
  • 保护目录要拦下:home / Desktop / Documents / Downloads 这些目录范围太大,会扫到一堆无关文件,直接在 validateWorkspacePath 里拒绝。

关键文件

数据流

每个任务第一次发 API 请求时,会顺手把 checkpoint 系统拉起来——如果初始化超时,后续整个任务都不再尝试 checkpoint,避免每轮都卡在超时上:

typescript
// core/task/index.ts:2906-2933
// Save checkpoint if this is the first API request
const isFirstRequest =
    this.messageStateHandler
        .getClineMessages()
        .filter((m) => m.say === "api_req_started").length === 0;

// Initialize checkpointManager first if enabled and it's the first request
if (
    isFirstRequest &&
    this.stateManager.getGlobalSettingsKey("enableCheckpointsSetting") &&
    this.checkpointManager && // TODO REVIEW: may be able to implement a replacement for the 15s timer
    !this.taskState.checkpointManagerErrorMessage
) {
    try {
        await ensureCheckpointInitialized({
            checkpointManager: this.checkpointManager,
        });
    } catch (error) {
        const errorMessage =
            error instanceof Error ? error.message : "Unknown error";
        Logger.error("Failed to initialize checkpoint manager:", errorMessage);
        this.taskState.checkpointManagerErrorMessage = errorMessage;
        HostProvider.window.showMessage({
            type: ShowMessageType.ERROR,
            message: `Checkpoint initialization timed out: ${errorMessage}`,
        });
    }
}

初始化成功后,任务在两个时机触发 saveCheckpoint:(1) 每个 attempt_completion 完成(AttemptCompletionHandler:156);(2) 一次 assistant 消息中所有工具都跑完之后(saveCheckpoint after tools:3811)。saveCheckpoint 内部不直接 commit,而是先 say("checkpoint_created") 占一条消息,然后异步 commit,commit hash 再回填到这条消息上:

typescript
// integrations/checkpoints/index.ts:166-187
const messageTs = await this.callbacks.say("checkpoint_created")
if (messageTs) {
    const messages = this.services.messageStateHandler.getClineMessages()
    const targetMessage = messages.find((m) => m.ts === messageTs)

    if (targetMessage) {
        this.state.checkpointTracker
            ?.commit()
            .then(async (commitHash) => {
                if (commitHash) {
                    targetMessage.lastCheckpointHash = commitHash
                    await this.services.messageStateHandler.saveClineMessagesAndUpdateHistory()
                }
            })
            .catch((error) => {
                Logger.error(
                    `[TaskCheckpointManager] Failed to create checkpoint commit for task ${this.task.taskId}:`,
                    error,
                )
            })
    }
}

之所以异步:LLM 的下一轮请求不用等 commit 写完就能继续。lastCheckpointHash 字段就是日后回滚的锚点。

回滚走 restoreCheckpoint,根据 restoreType 走不同分支:只还原代码就 resetHead(hash),只回退对话就修改 conversationHistoryDeletedRange,两者都做就都做。还原工作区时如果 tracker 还没起来,会现场 CheckpointTracker.create 一个。

边界与失败

  • home / Desktop / Documents / Downloads 直接拒绝(validateWorkspacePath:59),范围太大扫不动也容易撞权限。
  • 初始化超时一次就放弃整个任务(timeout guard:123),checkpointManagerErrorMessage 里带 Checkpoints initialization timed out. 标记,后续 saveCheckpoint 一进来就 return,不再重试。
  • 嵌套 .git 改名失败要重试(retryWithBackoff:223),addCheckpointFiles 在 finally 里用 3 次指数退避恢复嵌套 git,挂了就只记 error 不抛——否则用户子项目会一直处于 .git_disabled 状态。
  • 文件夹锁防并发(tryAcquireCheckpointLockWithRetry:220):同一个 cwdHash 下多个 Cline 实例可能互相踩,用 cwdHash 作锁键。VS Code 内部场景会跳过锁。
  • 连续 checkpoint_created 会去重(back-to-back guard:160):上一条消息已经是 checkpoint_created 就直接 return,避免空 commit 堆积。attempt_completion 还有单独的最近 3 条消息去重(completion dedup:192)。
  • 空 commit 用 --allow-empty(empty commit:251):即使工作区没变动,也要留一个 commit 占位,这样 hash 链条不断,回滚永远能找到锚点。
  • 二进制文件从 diff 结果里剔除(binary skip:434):无扩展名或 dotfile 路径会用 isBinaryFile 探一下,二进制就跳过,不然 diff 视图会糊一堆乱码。
  • 多 root 工作区只警告不报错(multiroot warn:461):检测到多 root 直接把错误信息塞进 taskState.checkpointManagerErrorMessage,UI 上展示警告,但任务不中断。

小结

Checkpoints 是 Cline 安全网的底座:用影子 git 把工作区状态序列化成一串 commit,再把 commit hash 贴到对话消息上,这样「时间点」和「对话点」就一一对应。上层只要知道 saveCheckpoint / restoreCheckpoint 两个 API,不用关心底下的 git 操作、嵌套 .git 处理、文件夹锁这些脏活。

回滚链路上的下一步看 agent-loop 的递归循环——recursivelyMakeClineRequests 在哪一步触发 saveCheckpoint、回滚后如何从对话历史的某个点重新喂给 LLM,详见该页。工具执行器把 saveCheckpoint 暴露给所有 handler 的细节在 工具执行 里。

对照官方资料:Cline 文档 · CheckpointTracker.ts