Checkpoints:任務回放的影子 Git
職責
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 活的是底下的 CheckpointTracker 和 GitOperations。
設計動機
- 不能改使用者的 .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裡拒絕。
關鍵檔案
class CheckpointTracker:50— 單個任務對應的影子 git 操控器,持有taskId和cwdHash。commit:212— 取得鎖 →git add .→git commit --allow-empty --no-verify,回傳 commit hash。resetHead:336—git reset --hard <hash>把工作區還原到指定 checkpoint。getDiffSet:397— 兩個 commit 之間(或 commit ↔ 工作區)的檔案差異,帶前後內容。initShadowGit:59— 首次建立影子 git 倉庫,設定core.worktree/user.email/ excludes。renameNestedGitRepos:148— 暫時停用/恢復巢狀.git目錄,繞開 submodule 限制。addCheckpointFiles:203— 停用巢狀 git →git add . --ignore-errors→ 恢復巢狀 git。getShadowGitPath:20— 影子 git 路徑globalStorage/checkpoints/{cwdHash}/.git。hashWorkingDir:103— 把工作目錄路徑 hash 成 13 位數字,作為影子 git 目錄名。saveCheckpoint:118— 業務入口,決定要不要建 commit、把 hash 貼到對應訊息上。restoreCheckpoint:238— 按messageTs找 checkpoint,按 restoreType 分支還原。buildCheckpointManager:59— 按 workspace 是單根還是多根,挑TaskCheckpointManager或MultiRootCheckpointManager。saveCheckpointCallback:1113— Task 暴露給工具執行器的回呼,工具完成後調它。ensureCheckpointInitialized:2920— 第一次 API 請求前確保影子 git 已經初始化好。
資料流
每個任務第一次發 API 請求時,會順手把 checkpoint 系統拉起來——如果初始化超時,後續整個任務都不再嘗試 checkpoint,避免每輪都卡在超時上:
// 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 再回填到這條訊息上:
// 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