Skip to content

pref(checkpoint):修复 Checkpoint 恢复与 Run 级 Diff 完整性#572

Open
phantom5099 wants to merge 3 commits into1024XEngineer:mainfrom
phantom5099:file-checkpoint
Open

pref(checkpoint):修复 Checkpoint 恢复与 Run 级 Diff 完整性#572
phantom5099 wants to merge 3 commits into1024XEngineer:mainfrom
phantom5099:file-checkpoint

Conversation

@phantom5099
Copy link
Copy Markdown
Collaborator

问题

1. 恢复后新文件残留

Agent 在运行过程中创建了新文件,restore 到更早的 checkpoint 时,新文件未被删除。根本原因是两层:

  • Finalize 只写本 turn 的 pending 子集。当某 turn 没有文件修改时,生成 CodeCheckpointRef="" 的 session-only checkpoint;restoreCheckpointCore 遇到非 per-edit ref 时整段跳过代码恢复路径。
  • 即使进入 Restore,当无 guard checkpoint 时,hashSet 仅包含目标 checkpoint 自身的 FileVersions,不包含后续创建的文件 -> for 循环遍历不到 -> 文件残留。

2. Restore 语义模糊

Restore 使用 v_next 单快照推断:对 checkpoint 中记录的版本号 v_A,查找其后的下一个版本作为"checkpoint 时刻状态"。这种方式对 post-delete 版本、删除后重新创建、目录操作等场景需要复杂的启发式判断,容易出错。

3. move 操作源文件残留

filesystem_move_file 执行后未调用 CapturePostDelete 标记源路径已删除,restore 时源文件错误地回到原地。

4. Run 级 diff 外部污染

CheckpointDiffscope=run 未实现。RunAggregateDiff 的 after-side 退化到 readWorkdirContent,会混入 run 结束后外部进程或用户手动做的修改。


方案

1. Finalize 全量快照 + FinalizePending 保护 Guard

  • Finalize 改为遍历 pathToVersions 全量索引,为每个 hash 取最新非 PostDelete 版本号,生成完整快照。这样每个 checkpoint 都有完整的 CodeCheckpointRef,restore 路径始终生效。
  • 新增 FinalizePending,仅写当前 turn 的 pending 子集,专用于 pre-restore guard checkpoint。避免 guard 包含多轮前的旧 pre-write 内容,导致 RestoreExact 在 undo 时写错状态。

2. 双快照对比恢复

Restore(targetID, guardID) 改为对比两个明确的状态快照

  • to 状态:目标 checkpoint 时刻的文件状态(通过 contentAtCheckpointLocked + v_next 语义)
  • from 状态:guard checkpoint 时刻(或当前磁盘)的文件状态
  • 一致则跳过;目标不存在则 os.RemoveAll;目标为目录则 MkdirAll;否则写回内容

消除了 v_next 单快照推断的歧义。

3. 无 guard 时合并全量索引

当用户主动 restore 到任意历史 checkpoint(无 guard)时,hashSet 额外合并 pathToVersions 中所有已知文件。这样新创建的文件被纳入遍历 -> toExists=false -> 正确删除。

4. IsPostDelete 标记区分

CapturePostDelete 写入 IsPostDelete=true 的 meta。Finalize 构建全量快照时跳过这些标记版本,确保 checkpoint 记录的是文件内容的最近版本号。pre-create 版本(Existed=false, IsPostDelete=false)仍然保留,使新建文件在 checkpoint 中可见。

5. RunAggregateDiff + RunID 过滤

  • 新增 RunAggregateDiff:对每个文件取最小版本作为 before baseline,取最大版本号的 v_next 作为 after-side(通过 contentAfterLastVersionLocked),避免 run 结束后外部修改污染。
  • ListCheckpointOpts 增加 RunID 字段,runDiffrun_id 精确收集 checkpoint,替代原来 Limit: 500 的粗暴方式。
  • CheckpointDiffInput 增加 ScopeRunID 字段。

6. move 操作补录 CapturePostDelete

toolexec.go 中为 filesystem_move_file 添加 CapturePostDelete(touchedPaths[0]),标记源文件已删除。


修改范围

文件 变更
internal/checkpoint/per_edit_snapshot.go 核心重构:Finalize 全量、FinalizePendingRestore 双快照、RestoreExactRunAggregateDiffcontentAfterLastVersionLocked
internal/checkpoint/per_edit_snapshot_test.go 测试适配(16 处 Restore 调用更新为三参数签名)
internal/checkpoint/checkpoint_manager.go ListCheckpointOpts 增加 RunID
internal/runtime/checkpoint_restore.go 提取 restoreCheckpointCore;guard 使用 FinalizePendingRestore 双参数调用;runDiff 实现
internal/runtime/checkpoint_gate.go lastEndOfTurnCheckpointID 记录;findPreviousEndOfTurnCheckpoint 辅助方法
internal/runtime/toolexec.go CapturePostDelete for move;bash side effect 路径记录
internal/repository/fingerprint.go checkpoint 包迁移 ScanWorkdir/DiffFingerprints
docs/reference/gateway-rpc-api.md CheckpointDiffInput 增加 Scope/RunID 文档

预期收益

  1. 恢复正确性:新文件创建、文件删除、文件修改、目录操作四种场景全部正确恢复,不再出现文件遗漏或错误回退。
  2. 语义清晰:从 v_next 单快照推断改为双快照对比,消除了复杂启发式判断,代码更易维护。
  3. Undo 可靠:guard checkpoint 通过 FinalizePending 精确固化 restore 前状态,undo restore 行为一致。
  4. Run diff 精准scope=run 模式可展示一次完整 run 的端到端代码变更,不被外部修改干扰。

TUI/GUI 接入方式

GUI(Web)已接入

  • CheckpointInlineMark 组件内联展示每个 checkpoint,支持 Restore 和 Undo Restore 操作,通过 gatewayAPI.restoreCheckpoint / gatewayAPI.undoRestore RPC 调用。
  • eventBridge 消费 CheckpointCreated / CheckpointRestored / CheckpointUndoRestore 事件,更新 insight store 和 UI toast 提示。
  • gatewayAPI.checkpointDiff 展示 checkpoint 间差异(目前通过 CheckpointDiffParamssession_id + checkpoint_id)。

GUI 待接入点

  • CheckpointDiffParams 接口需扩展 scoperun_id 字段,即可接入 scope=run 的 run 级聚合 diff。接入后可在 Run 结束时自动展示本次 run 的端到端代码变更摘要。
  • EventRunDiffSummary 已定义但未在 eventBridge 中消费,待接入后可在消息流中内联展示 run 级 diff 卡片。

TUI 接入

  • TUI 已消费 EventCheckpointCreated / EventCheckpointRestored / EventCheckpointUndoRestore 事件(remote_runtime_adapter 转发)。
  • runDiff 已暴露为 CheckpointDiff RPC 方法,TUI 侧扩展参数即可调用。

@chatgpt-codex-connector
Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

fennoai[bot]

This comment was marked as outdated.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 7, 2026

@phantom5099
Copy link
Copy Markdown
Collaborator Author

/review 检查一遍还有没有存在checkpoints创建出错、run级diff存在遗漏或是参杂外部修改、代码回退出现遗漏等问题

Copy link
Copy Markdown

@fennoai fennoai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

发现 3 个需要处理的问题,集中在 scope=run 的结果稳定性和 restore 的边界场景。

}
afterContent, afterIsDir, afterExists, degraded := s.contentAfterLastVersionLocked(hash, vr.maxV, display)
if degraded {
if info, err := os.Stat(display); err == nil && info.ModTime().After(runEndTime) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RunAggregateDiff 这里仍然会把 run 结束之后发生的删除 误算进来。contentAfterLastVersionLocked 退化到 workdir 后,如果文件已经被后续操作删掉,os.Stat(display) 会直接报 ENOENT,这段保护逻辑就失效了,随后 afterExists=false 会把它分类成“本次 run 删除了该文件”。也就是说,这次改动虽然过滤了“run 后修改但文件仍存在”的场景,却没有过滤“run 后删除”的场景,scope=run 仍然会被后续工作区变更污染。

if err != nil {
return fmt.Errorf("per-edit: read bin v%d: %w", nextVersion, err)

if toIsDir {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restore 现在仍然没有处理文件/目录类型切换。若当前 display 是文件而目标是目录,os.MkdirAll(display, ...) 会失败;反过来如果当前是目录而目标是文件,后面的 writeFileAtomic(..., display) 也会因为目标路径已被目录占用而失败。这个 PR 强化了目录恢复语义,但这里缺少“类型不一致时先移除旧节点”的处理,历史里出现 file<->dir 变更时 restore 还是会中断。

}
perEditID := checkpoint.PerEditCheckpointIDFromRef(r.CodeCheckpointRef)
perEditIDs = append(perEditIDs, perEditID)
if firstPerEditCheckpointID == "" {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里把第一个遍历到的记录当成 firstPerEditCheckpointID,但 ListCheckpoints 在 SQLite 实现里是按 created_at_ms DESC 返回的。所以线上路径会把 run 中最新的 checkpoint 填进结果,而不是变量名、测试期望和接口语义里的“第一个 checkpoint”。当前测试里的 spy 恰好按升序喂数据,所以看不出来;真实存储下这个字段会反过来。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant