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