Skip to content

feat(checkpoint): 会话级文件改动检查点与代码回退(P1) - #504

Merged
su-fen merged 12 commits into
Stack-Cairn:mainfrom
xiaozhou26:feat/checkpoint-rewind
Aug 17, 2026
Merged

feat(checkpoint): 会话级文件改动检查点与代码回退(P1)#504
su-fen merged 12 commits into
Stack-Cairn:mainfrom
xiaozhou26:feat/checkpoint-rewind

Conversation

@xiaozhou26

@xiaozhou26 xiaozhou26 commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Closes #503

Summary

  • 在 Rust 端 fs_write_text / fs_edit_text / fs_delete 落盘前原子捕获前像(pre-image),随文件变更一体完成,不新增 IPC;存储于 ~/.liveagent/checkpoints/<conversationId>/(原始字节 blob + 追加式 index.jsonl
  • 新增 checkpoint.rs 模块与三个命令:checkpoint_list(按轮汇总)、checkpoint_diff_stats(回退影响预览)、checkpoint_rewind_code(执行回退)
  • TS 侧穿线:createFsToolsbuiltinRegistryrunAgentConversationTurn 传入 {conversationId, turnSeq};子代理注册表经参数展开自动继承
  • 桌面端顶栏新增 CheckpointRewindMenu:列出可回退轮 → diff 统计 + 受影响路径确认框 → 回退,失败逐条报告

Screenshots / preview

顶栏入口(主题切换与项目工具开关之间的 History 图标):

顶栏入口

空状态(本会话暂无检查点,底部为覆盖范围说明):

空状态

有可回退轮时的列表(按轮汇总时间与文件数):

检查点列表

回退确认框(diff 统计 + 受影响路径预览):

回退确认

设计要点

  • 回退语义:取 turnSeq >= 目标轮 的记录,每路径保留最早一条前像;索引追加式不截断,回退后继续对话再回退仍自洽
  • best-effort 捕获:失败仅 eprintln!,绝不阻断文件写入
  • 不捕获的场景:Cron 自动化、Gateway 桥(WebUI 文件管理器)显式传 None;符号链接不捕获(恢复语义不明确)
  • 边界(UI 中明示):Bash/shell 产生的改动不在范围;目录删除记不可恢复标记仅计数提示
  • 仅动桌面端(GUI + Tauri),共享 agent-ui 与 WebUI/proto 均未触碰(WebUI 入口属 P2)

Test plan

  • cargo check --tests 通过
  • cargo test checkpoint --lib 9 个新单测全绿(往返恢复、删除标记、跨轮最早前像、同轮去重、目录标记、父目录重建、分组/diff 分类)
  • pnpm -C crates/agent-gui build(tsc + vite)通过
  • biome:新增/改动代码零新错误(既有基线除外)
  • node --test builtin-registry 子代理测试 6/6
  • git diff --check 无尾随空白

在 fs_write_text / fs_edit_text / fs_delete 落盘前原子捕获前像,
存储于 ~/.liveagent/checkpoints/(blob + 追加式 index.jsonl);
新增 checkpoint_list / checkpoint_diff_stats / checkpoint_rewind_code
三个命令,桌面端顶栏提供按轮回退入口(diff 预览 + 确认)。

- 捕获失败仅记日志,不阻断文件写入
- Cron / WebUI 文件管理器等非对话场景不捕获
- Bash 产生的改动不在范围内(UI 明示);目录删除记不可恢复标记
- 索引追加式不截断,回退后再回退语义自洽

Closes Stack-Cairn#503
@StackCairn
StackCairn marked this pull request as draft August 16, 2026 07:07
@github-actions

github-actions Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

PR governance checks passed. Awaiting human review.

@xiaozhou26
xiaozhou26 marked this pull request as ready for review August 16, 2026 07:15

@yovinchen yovinchen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

整体方向是对的:把 checkpoint 捕获放进 fs_write_text / fs_edit_text / fs_delete 的实际落盘路径,使用前像 blob 与追加索引,不依赖 Git,并在 UI 中明确 Shell 不受覆盖,这些都适合作为 P1 的实现基础。现有单测和 diff 预览也比较完整。

不过结合 Claude Code 的官方 rewind 语义和 LiveAgent 当前的 Worktree、Root Grant、并发会话模型,我认为目前仍有两个阻断合并的问题,以及几项需要明确处理或拆分 follow-up 的问题。

必须修复

1. Rewind 未重新校验路径,可能越过工作区边界写文件

CheckpointRecord 保存绝对路径,rewind_at 恢复时直接通过 PathBuf::from(&record.path) 执行 fs::write / remove_file。这里没有重新经过 Workspace Root、Root Grant 和 scoped path resolver,也没有检查路径当前是否已经变成 symlink/hardlink。

例如:

  1. Agent 编辑 workspace/a.txt,checkpoint 保存该路径。
  2. 后续 Bash 将 a.txt 替换为指向工作区外文件的符号链接。
  3. 用户执行 rewind。
  4. fs::write(workspace/a.txt, ...) 会跟随链接,覆盖工作区外的文件。

这不是只在未来开放 WebUI 后才存在的问题;当前桌面端配合 Bash 或外部文件变动即可触发。Claude Code 明确跳过 symlink/hardlink restore,正是为了避免这类恢复边界问题。

建议:

  • Checkpoint 保存 root identity + relative path,不要把绝对路径作为恢复授权依据。
  • 恢复时重新经过当前 Root Grant/path resolver。
  • 使用 symlink_metadata 检查路径及父目录链,拒绝 symlink;Unix 下检查硬链接数量。
  • 比较 preview 后的当前内容哈希,避免确认框与实际写入之间的 TOCTOU。
  • 使用临时文件加原子 rename,而不是直接 fs::write

2. Worktree 子代理的 checkpoint 记录在错误的工作区

buildBuiltinToolRegistry 通过 { ...params } 把主轮 checkpoint 传进了子代理注册表;但 mode=worktree 的子代理实际在临时 worktree.workdir 中运行,完成后再通过 worktree.apply 把改动应用到主工作区。

因此当前流程是:

  1. 子代理编辑临时 Worktree 文件。
  2. Checkpoint 保存临时 Worktree 的绝对路径。
  3. Worktree 补丁被应用到主工作区。
  4. 临时 Worktree 被清理。
  5. Rewind 恢复的是已经删除的临时路径,主工作区改动没有被恢复。

对于已有文件,rewind 甚至可能重新创建废弃 Worktree 目录;对于新文件,主工作区文件仍会保留。因此“子代理自动继承 checkpoint,改动一并捕获”目前并不成立。

建议不要向 Worktree 子代理传递父轮 checkpoint,而是在 worktree.apply 修改主工作区之前,对实际 changedPaths/deletedFiles 捕获主工作区前像。

高优先级问题

3. Date.now() 不能作为轮次序列

当前 turnSeq 同时承担顺序标识、同轮去重键和 UI 时间戳,但 Date.now() 不是单调时钟,系统时间回拨会破坏 turnSeq >= target 的恢复语义。仅增加类型注释不足以保证正确性。

建议使用持久化的 conversation turn/message ID,并另外保存 createdAt 供 UI 展示;至少也应使用会话内严格递增序号。

4. 追加式索引没有时间线或 generation 语义

回退到早期轮次后,菜单仍保留后面的旧 checkpoint。继续产生新改动后,再选择旧的未来轮次,会混合已经放弃的旧时间线和当前时间线,形成难以预测的“部分 redo”。

建议引入 checkpoint generation/branch:Rewind 后产生新的 generation;默认隐藏废弃 generation 的未来 checkpoint;如果保留访问入口,需要明确标记为旧时间线,而不是继续用单一 turnSeq >= target 聚合。

5. 捕获失败对用户不可见

capture_pre_image 失败只输出 eprintln!,写入仍继续。这个 best-effort 取舍可以接受,但当前没有记录“本轮 checkpoint 不完整”,因此捕获从未写入索引时,missingBlobs 无法发现;UI 可能报告 rewind 成功,但部分文件实际没有恢复。

建议给每轮增加 manifest/status,例如 complete / incomplete,捕获失败时记录原因,并在恢复确认中明确警告或禁止无提示恢复。

6. Rewind 会静默覆盖手工和并发修改

Diff preview 和实际 rewind 是两次独立调用,中间没有文件锁或内容版本校验。用户、其他会话或后台运行可能在确认后修改文件,随后仍被旧前像覆盖。

建议在 preview 返回 expected-current hash,rewind 时重新比较;发生变化默认跳过并报告 conflict,而不是覆盖。Rewind 期间也应禁止当前会话及同项目运行继续写文件。

建议本 PR 最低补齐

  • 修复恢复路径重新授权、symlink/hardlink 和 TOCTOU。
  • 修复 Worktree 子代理捕获位置。
  • 用稳定 turn identity 替代 Date.now() 顺序。
  • 增加 incomplete checkpoint 状态。
  • Rewind 后失效文件工具缓存,并向 transcript/runtime 写入明确的 rewind 事件。
  • 增加最基本的容量限制、文件权限和清理入口。

完整的“代码 / 对话 / 两者 / summarize”可以继续放在 P2;但存储不能无限增长。当前会永久保存完整文件内容,包括可能的密钥和大型文件。即使完整 GC 放在 P3,也建议 P1 至少增加单文件大小、会话总容量、checkpoint 数量限制,以及 0700/0600 权限。

建议补充以下测试:

  • 文件在 preview 后被修改。
  • 文件或父目录在捕获后被替换为 symlink。
  • hardlink 文件恢复。
  • Worktree 子代理修改、新建和删除文件。
  • 系统时间回拨或重复 turn ID。
  • Rewind 后继续工作,再选择旧未来 checkpoint。
  • 捕获失败、索引截断、blob 丢失。
  • 两个会话同时修改同一文件。
  • 大文件删除和存储容量上限。

综上,这个 PR 的 P1 范围本身合理,但当前不能描述为“缩水但自洽的 Claude Code rewind”:主会话线性路径基本自洽,Worktree 子代理和恢复授权路径目前并不自洽。建议修复上述两个阻断问题后再合并,其余能力可以按明确的 P2/P3 issue 继续推进。

阻断 1:检查点改存 root+相对路径(schema v2),恢复前重新解析并
逐级拒绝符号链接/多硬链接,临时文件+原子 rename 落盘;旧 v1 索引
行静默跳过。

阻断 2:worktree 子代理不再继承父轮 checkpoint;subagent_worktree_apply
在改写父工作区前对实际 apply 路径以父仓库根捕获前像。

最低补齐:turnId 稳定标识(UUID,Rust 侧时钟无关分配 turn_seq)、
捕获失败 error 记录与 UI 不完整警示、预览哈希回带的冲突检测
(不一致跳过不覆盖)、回退完成显式通知、容量上限(32MB/512MB/10k)
与 0700/0600 权限、checkpoint_clear 清理命令、rewind 审计标记。
@xiaozhou26

Copy link
Copy Markdown
Contributor Author

评审已全部处理,修复见 0b5635d。逐项对应:

本 PR 已修复

阻断 1 — Rewind 路径校验

  • 检查点记录改存 root + 相对路径(schema v2),不再以捕获时的绝对路径作恢复授权
  • 恢复前重新解析:root canonicalize、拒绝非常规路径段、父链逐级 symlink_metadata 拒符号链接、Unix 下拒多硬链接目标
  • 写回走临时文件 + 原子 rename
  • 旧 v1 索引行按 schema 字段静默跳过,无迁移

阻断 2 — Worktree 子代理工作区错位

  • 子代理工具注册表显式不继承父轮 checkpoint
  • subagent_worktree_applystage_apply_paths 改写父工作区之前,对实际 apply 路径以父仓库根捕获前像;checkpoint 上下文经 apply 请求线程到后端

最低补齐

  • 轮标识:前端 crypto.randomUUID() 生成稳定 turnId,Rust 在索引锁下分配时钟无关的单调 turn_seq;UI 展示改用独立的 firstCapturedAt
  • 不完整状态:捕获失败/超限追加 kind="error" 记录,列表与确认框显示警示
  • 冲突检测:diff 预览返回每条目 (key, currentHash),rewind 前逐个复核,不一致的跳过并在结果中上报 conflicts,绝不覆盖
  • 回退事件:回退完成后显式通知用户;文件工具缓存为结构性保证(注册表与 fileState 每用户轮重建,回退入口在发送中禁用)
  • 容量与权限:单文件 32MB、会话总量 512MB、记录数 10k 上限;Unix 目录 0700 / 文件 0600;新增 checkpoint_clear 清理命令
  • 审计:回退后追加惰性 kind="rewind" 标记(不参与恢复语义)

新增测试(checkpoint 22 个,含):预览后修改冲突跳过、符号链接换目标/换父目录拒绝、多硬链接拒绝、worktree apply 父工作区前像、时钟回拨/重复 turnId、超限 error 记录、blob 缺失不触碰文件、v1 行忽略、rewind 标记惰性。

按 P2/P3 拆分继续推进

  • P2:对话/both/fork 恢复模式、WebUI 入口
  • P3:GC 完整方案(P1 已有容量上限 + checkpoint_clear 兜底,不会无界增长)

验证:cargo test checkpoint/subagent_worktree --lib 全绿,GUI tsc/build 通过,前端测试无新增失败。

@xiaozhou26
xiaozhou26 requested a review from yovinchen August 16, 2026 13:03

@yovinchen yovinchen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

感谢按上一轮 review 做了系统性修复。我基于 d8ca2ef..a761b8e 做了增量复审,并在隔离 checkout 中执行了:

  • cargo test --manifest-path crates/agent-gui/src-tauri/Cargo.toml checkpoint --lib:25/25 通过
  • cargo test --manifest-path crates/agent-gui/src-tauri/Cargo.toml subagent_worktree --lib:9/9 通过
  • PR 当前全部 CI 通过

以下修改已经确认有效:

  • Worktree 子代理不再捕获临时目录,父工作区前像改在 apply 前捕获。
  • Date.now() 已替换为稳定 turnId,Rust 侧分配单调 turn_seq
  • 增加了 incomplete/error 记录、哈希冲突结果、Unix 权限、容量上限和清理入口。
  • 普通目标 symlink swap、父目录 symlink、hardlink 和 blob 丢失都有对应测试。

不过复审仍发现两个可复现的阻断问题,因此本轮继续 Request changes。

必须修复

1. 根目录本身被替换为 symlink 时,rewind 仍会写到原工作区之外

resolve_rewind_target 先对索引中的 root 执行 fs::canonicalize,随后只检查 rel_path 的路径链。canonicalize 会直接跟随 root 自身的符号链接,因此如果捕获后原工作区目录被移动,并在原路径放置一个指向外部目录的 symlink,rewind 会把外部目录当成新的合法 root。

我在隔离 checkout 中补了最小测试,当前行为可以稳定复现:

  1. workspace/a.txt 捕获 v1 前像。
  2. workspace 重命名为 workspace-old
  3. 创建 workspace -> outside symlink,outside/a.txt 内容为 outside-secret
  4. 执行 rewind。
  5. 返回 restored_files=1,且 outside/a.txt 被覆盖成 v1

因此当前实现仍没有完成“恢复时重新授权”:它把索引里的 root 字符串继续当作授权来源,也没有校验它是否仍属于当前 Workspace Project / Root Grant。

建议至少:

  • 在 canonicalize 前先用 symlink_metadata(root) 拒绝 root 本身是 symlink。
  • 将当前会话仍然有效的 Workspace Project/Root Grant 身份传入后端,恢复时确认记录 root 仍在授权集合中;不能仅依赖索引保存的绝对 root。
  • 对 canonicalize 后的 root 与捕获时身份做一致性校验。
  • 写入前再次验证父链,避免校验后到 rename 前父目录被替换。

2. 预览时为 clean 的文件没有回传哈希,确认后仍可能覆盖手工修改

前端只把 restore/delete 条目放进 actionable,然后只回传这些条目的 (key, currentHash)。后端则把 expected 做成可选 Map;某个 key 不存在时直接跳过冲突检查。

这会产生以下情况:

  1. 某文件在 preview 时已经与前像一致,分类为 clean,因此不进入 expected
  2. 用户在确认框打开期间手工修改该文件。
  3. rewind 遍历该记录时找不到 expected hash,于是不做冲突检查。
  4. 用户刚做的修改被前像覆盖。

我同样用临时回归测试复现了当前行为:返回 conflicts=0restored_files=1,确认后的手工修改被覆盖。

建议:

  • 所有可解析的 file 记录都必须回传 preview hash,包括 clean
  • 后端应 fail closed:只要 expected 集合缺少某个待处理 key,就按 conflict/invalid preview 处理,不能默认为允许覆盖。
  • expected: None 只应保留给明确的内部测试或 force API,不应成为桌面 UI 命令的正常路径。

仍需补齐

3. 32 MB 限制是在整文件读入内存后检查

PreImage::File(None) 当前先执行 fs::read(&abs_path),之后才判断 bytes.len() > MAX_BLOB_BYTESfs_delete 可以删除任意普通文件,因此删除超大文件时仍会先把整个文件读入内存,容量限制无法防止内存和 I/O 峰值。

现有 oversized_file_records_error_instead_of_blob 测试没有执行这个分支,只是手工追加了一条 error 记录。建议先读取 metadata length,超过限制直接记录 incomplete,不要调用 fs::read,并增加真实的超限分支测试。

4. Rewind marker 仍未解决旧未来时间线问题

kind="rewind" 当前只是审计记录,earliest_records_since 会直接忽略它。回退到早期轮次后,旧的未来 checkpoint 仍显示在菜单中,也仍会与回退后新增的记录一起参与 turn_seq >= target 聚合,之前指出的“部分 redo / 混合时间线”语义仍然存在。

如果 generation/branch 明确留到 P2,本 PR 至少应在 rewind 后隐藏或标记旧未来轮次,并在 UI 明示其语义;当前注释中的“索引不截断且语义自洽”仍不准确。

5. Worktree 遇到 symlink 或 metadata 错误时仍静默跳过捕获

capture_worktree_apply_pre_images 对 symlink/其他类型以及非 NotFound 的 metadata 错误直接 continue,不会写 error 记录。因此 Worktree apply 可以修改这些路径,但该轮不会被标记 incomplete,用户也得不到 Claude Code 类似的 skipped warning。

建议将这些跳过原因写成 kind="error" 或明确的 skip-link 记录,让 diff/result 能如实报告不可恢复路径。

整体上,这次修改已经解决了上一轮大部分问题,Worktree 捕获方向也正确;但上述前两个问题仍会造成工作区外写入或静默覆盖确认后的手工改动,需要修复后再批准。

回退不再把记录里的绝对路径当授权凭据:root 自身是符号链接一律拒绝,
且必须命中调用方给出的当前授权根集合(会话工作区根 + 仍 active 的额外
授权根),写入前紧邻再校验一次整条路径链。工作区被改名后在原路径放符号
链接指向外部目录的换根攻击因此失效。

冲突检测从"只带 restore/delete"改为回传全部可解析条目(含 clean),后端
对缺哈希的条目一律判冲突而非跳过检查——确认期间被手改的干净文件不再被
静默覆盖。

另外:大小上限改为先看 metadata 再读文件,避免为拒绝而先把超限文件读进
内存;完整成功的回退写下剪枝标记,读取侧据此丢弃已撤销的陈旧未来轮次;
worktree 前像捕获的失败不再静默 continue,改记 error 记录。

自查补强:授权根的仓库根推导在主目录/文件系统根封顶,避免主目录本身是
dotfiles 仓库时把整个主目录变成可写目标;回退全程持 INDEX_LOCK,防止并发
捕获被剪枝标记连带埋掉;skipped_dirs 计入"未回退干净";rewind 标记不参与
turnId 复用;"unreadable" 哨兵不再自我匹配。
@StackCairn
StackCairn marked this pull request as draft August 16, 2026 16:04
@xiaozhou26

Copy link
Copy Markdown
Contributor Author

5 项已全部处理,a761b8e..a14436c0

Blocker

1. 换根攻击(root 符号链接)

resolve_rewind_target 之前先 canonicalize 存下来的 root,工作区被改名后在原路径放个指向外部目录的符号链接就能把回退写出去。现在三道:

  • resolve_authorized_root 先用 symlink_metadata 判定,root 自身是符号链接直接拒绝,不给 canonicalize 机会;
  • 前端在动作发生时(不是渲染时)解析授权根集合 = 会话工作区根 + state === "active"WorkspaceRootGrant.canonicalPath,随 checkpoint_diff_stats / checkpoint_rewind_code 传下来;后端只认这个集合,记录里的绝对路径本身不再构成授权,空集合 = 什么都回退不了;
  • 写入前紧邻再跑一遍完整解析(reverify_target),结果与预览时的目标不一致就判失败,堵窗口期。

授权集合里另有两类后端自行推导的根,不接受调用方传入,所以不构成新入口:Skills 根(skill:// 写入的前像记在那里)与各授权根所在的 git 仓库根(subagent worktree apply 把父工作区前像记在 parent_repo_root)。漏掉这两类会让对应轮次永久回退不了。

2. clean 条目 fail-open

前端原先只回传 restore/delete 的哈希,后端缺键就跳过检查——确认框停留期间被手改的 clean 文件会被静默覆盖。现在前端回传全部可解析条目entry.currentHash != null 的全集,与"目标可解析"等价),后端缺键一律判冲突。新增 missing_expected_hash_is_treated_as_conflict 覆盖。

补齐

3. PreImage::File(None) 改为先看 metadata().len() 再决定读不读,不再为了拒绝而先把超限文件读进内存。新增 oversized_file_records_error_instead_of_blob

4. kind="rewind" 标记不再是死记录:完整成功的回退写 turn_seq=target,读取侧据此丢弃 >= target 的陈旧未来轮;有冲突/失败/跳过时写 turn_seq=0,只审计不剪枝。注释同步修正。新增 rewind_marker_cuts_stale_future_turns / partial_rewind_marker_does_not_cut_timeline

5. capture_worktree_apply_pre_images 的裸 continue 改为写 kind="error" 记录,该轮标记为不完整。新增 worktree_pre_image_classification_reports_skips

自查补强

改完又跑了一轮自审,修掉 5 处(其中前两处是本轮引入的回归):

  • enclosing_repo_root 向上找仓库根是无界的,主目录本身是 dotfiles 仓库时会把整个主目录变成可写回退目标——现在在主目录与文件系统根处封顶;
  • 回退全程持 INDEX_LOCK(原先只在写标记时短暂持有),否则回退期间落盘的捕获会被随后的剪枝标记连带埋掉;
  • skipped_dirs > 0 计入"没回退干净",被递归删除的目录恢复不了就不该剪时间线;
  • resolve_turn_seq 排除 rewind 标记,否则空 turnId 会复用到哨兵的 turn_seq=0
  • "unreadable" 是"没读到内容"的哨兵不是内容摘要,两次都没读到不等于没变,不再自我匹配。

验证

cargo test ... checkpoint --lib 29 passed / 0 failed;cargo check --tests 零错误(4 条警告是 shell_runner / managed_process 既有基线);pnpm -C crates/agent-gui build 通过;pnpm test:gui 无新增失败;biome 对改动文件零错误。

已知遗留(本轮未动,均为 P1 既有问题,建议另开)

  1. subagent_worktree.rs 捕获早于「空 patch 提前返回」,可能留下 existed_before=false 的幽灵删除记录
  2. atomic_write 在 Windows 上 delete-then-rename,遇共享冲突可能两份都丢
  3. 工作区内符号链接会让 rel 与实际写入目标分叉
  4. 前像不含权限位,回退不还原 mode(要升 schema v3)
  5. 存储配额口径:bytes.len() vs metadata().len() 不一致;total 把已剪枝记录算进去;checkpoint_clear 暂无调用方
  6. 压缩账本在回退后未失效
  7. isSending 不关闭已展开的回退菜单

需要的话我可以逐条开 issue。

- atomic_write 改为“旧文件挪备份 → rename → 失败挪回”:Windows 上目标被
  占用时,原先的 remove+rename 兜底会让新旧内容同时消失
- size 记实际落盘字节数,配额不再因“调用方直接给 bytes”而失真
- 预留 64 条尾部名额给 error 记录,撞条数上限的轮次仍能如实标记不完整
- 捕获前像时一并记 Unix 权限位并在回退时还原,脚本不会回退完就丢 +x
- fs 捕获点改用 canonicalize 后的真实相对路径:工作区内符号链接下,前像
  不再挂到没被改动的路径上,也不再因逐级拒符号链接而永远回退不了
- worktree 前像捕获移到 empty_patch 提前返回之后,空补丁不再留下一批
  existed_before=false 记录、回退时反删父工作区已有文件
- INDEX_LOCK 各处从中毒恢复,一次无关 panic 不会让检查点子系统整体失效
- 菜单受控开合,isSending 期间强制收起;确认框说明手改不在检查点内
- 删除会话时清理检查点目录,此前该数据永不回收
@xiaozhou26

Copy link
Copy Markdown
Contributor Author

补充一轮:/code-review 全量复审的遗留项已一并修完(70a5e279

上一条回复只覆盖了 review 提出的 5 项。之后我对整个 checkpoint 子系统跑了一轮全量复审,又找出 11 处问题,现在全部处理完毕。

回退写入的数据安全

atomic_write 在 Windows 上会丢内容。 原先目标被占用(编辑器/杀软持句柄)导致 rename 失败时,兜底是 remove 目标再 rename;若 remove 成功而 rename 仍失败,新旧内容会同时消失——回退反而把文件弄丢。改为「旧文件挪到备份名 → rename → 失败则把备份挪回」,任何一步失败都至少保住一份完整内容。

回退会吃掉可执行位。 atomic_write 走的是新建临时文件 + rename,新文件带默认权限,内容对了但 +x 丢了的脚本仍然是坏的。新增可选字段 mode: Option<u32>,Unix 下捕获时记权限位、回退时还原(含「内容已一致」的短路分支)。v2 老记录读出来是 None,跳过还原即可,不需要升 schema。

符号链接下的前像归属

resolve_target 会解析工作区内部的符号链接,所以请求路径(link/a.txt)和实际落盘路径(real/a.txt)可能分叉。此前四处捕获点都按请求路径记录,后果有两个:前像挂在一个根本没被改动的路径上;回退时逐级拒符号链接又会把它判成不可解析,于是这条改动永远回退不了。新增 checkpoint_rel(),统一按 canonicalize 之后的真实路径记录。

worktree 前像捕获位置

capture_worktree_apply_pre_images 原先在 stage_apply_paths 之前调用,早于 empty_patch 的提前返回。那条路径下父仓库一个字节都没动,却会留下一批 existed_before=false 的记录——将来回退时反而把父工作区里本来就存在的文件删掉。已移到 empty_patch 返回之后、run_git_apply_with_options 之前。

索引与配额的记账正确性

  • size 记错了口径。 原先取 metadata().len(),在「调用方直接给 bytes」或读盘后文件又被改的情况下与实际落盘量对不上,512MB 总配额跟着失真。改记 bytes.len()
  • 撞条数上限的轮次会伪装成完整。 上限满了之后 error 记录也写不进去,UI 上那一轮不带 ⚠,看起来完好无损。新增 RECORD_CAP_ERROR_RESERVE = 64:普通捕获提前 64 条停住,尾部名额留给 error 记录。record_capture_skip 撞上限时也改为打日志而非静默丢弃。
  • INDEX_LOCK 中毒会让子系统整体死掉。 这把锁守的是 (),没有任何不变量会因 panic 而损坏,但一次无关 panic 会让此后所有捕获和回退全部失败。各处改用 into_inner() 恢复。

前端

  • isSending 关不掉已展开的菜单。 disabled 只作用在 trigger 上;用户先展开菜单、再发出新一轮消息时,列表还开着且条目可点,回退会踩在正在写入的半截时间线上。改为受控开合,disabled 一真强制收起。
  • 确认框补充手改提示。 检查点只记录 agent 工具写入前的前像,编辑器/文件树里的手改既不入账、也无法与工具写入区分;回退按前像整体覆盖会把手改一并抹掉。有实际写入动作时在确认框里明确说明。
  • checkpoint_clear 此前零调用。 命令已注册但没有任何调用方,检查点目录(索引 + blobs)以会话为单位存放且没有独立 GC,单个会话最多能压着 512MB blob 永不回收。已接到删除会话的路径上,尽力而为,清理失败不反过来让删除报错。

一处明确不修的

压缩摘要的 fileLedger 持久化在历史里,不随轮次重建,回退后仍会列出那些路径。但账本语义是「曾被触碰的路径」,不断言当前内容,回退后这个陈述仍然成立,不算失真。真正会过时的是摘要正文里模型写的完成情况,那需要改写已落库的摘要——需要一条目前不存在的历史变更通道,超出本 PR 范围。已在 ChatPage.tsx 的回退回调处写成已知限制注释。

验证

  • cargo check --tests 通过(仅 4 条既有 warning,均在 shell_runner.rs / managed_process.rs,与本 PR 无关)
  • cargo test checkpoint --lib:29 passed / 0 failed
  • pnpm -C crates/agent-gui build 通过
  • pnpm test:gui:5 个既有失败用例(truncated snapshot / composer caret / insertNodeAtCursor / preset scripts / send preflight),与改动前基线一致,无新增
  • biome check(LF 副本,避免本机 CRLF 噪声):0 error

…feat/checkpoint-rewind

# Conflicts:
#	crates/agent-gui/src/pages/ChatPage.tsx
@xiaozhou26
xiaozhou26 requested a review from yovinchen August 16, 2026 16:38
@xiaozhou26

Copy link
Copy Markdown
Contributor Author

已合并 main752e9da4)解决冲突,分支现为 MERGEABLE。

冲突只有一处:ChatPage.tsx 里 header 相关的 props 在 main 上被重构走了——原先挂在 chat={{ ... }} 上的 trailingActions / headerOverlay / 模型选择相关字段,现在分别落到独立的 <AppWorkbenchChrome><ConversationSurface>。冲突块整体采用 main 侧,CheckpointRewindMenu 迁到 AppWorkbenchChrometrailingActions 里、与 ProjectToolsPanelToggle 并列,行为不变。

合并后验证:cargo test checkpoint --lib 29 passed / 0 failed;pnpm -C crates/agent-gui build 通过;pnpm test:gui 仍是合并前那 5 个既有失败用例,无新增;biome check 0 error。

@yovinchen 两轮 review 的意见连同后续全量复审的遗留项都已处理完(见上一条回复),麻烦有空时再看一下。

unwrap_err() 要求 Ok 类型实现 Debug,而 PreImage::File 装的就是文件内容,
不该为了一句测试断言让它可以被打印进 panic 信息。改用 let-else,既不需要
Debug,也不会有把前像字节写进日志的路径。

该测试是 #[cfg(unix)] 门控的,Windows 上根本不编译,所以本地检查发现不了。
@xiaozhou26
xiaozhou26 marked this pull request as ready for review August 16, 2026 16:56
三个 P1:

1. 不完整检查点会被误判为完整回退。rewind_at 丢弃了
   earliest_records_since 返回的 error 计数,checkpoint_rewind_code_sync
   据此写下 turn_seq=target 的完整剪枝标记——大文件/捕获失败的轮次回退后
   显示成功,error 记录连同"没回退干净"的事实被剪枝永久藏掉。现在
   CheckpointRewindResult 携带 capture_errors,完整性判定
   (rewind_is_complete)将其计入,有缺口只写 turn_seq=0 的审计标记不剪枝;
   前端在部分完成对话框与通知里明示"N 个无前像未回退"。

2. 权限修改绕过预览和冲突检测。current_state_hash 只哈希内容,而回退的
   "内容已一致"短路分支会还原权限位——确认框停留期间的 chmod 被静默覆盖,
   纯权限漂移还被预览伪装成 clean。状态指纹改为"内容哈希@八进制 mode"
   (Windows 无 POSIX 位退化为纯内容),预览分类用 mode_differs 把纯权限
   漂移判为 restore,rewind 的 TOCTOU 比对自然连权限一起校验。

3. 经工作区内部符号链接新建的文件无法回退。fs_write_text 的 NotFound 分支
   直接用未解析的 raw_target 落盘,检查点记下链接路径(link/new.txt);回退
   侧逐级拒符号链接,这条"删除该文件"的记录永远不可解析。ensure_parent_dir
   现在返回 canonical 父目录,新建文件的落盘目标与检查点相对路径都基于
   真实路径(real/new.txt),与 existed_before 分支的 resolve 语义对齐。

新增测试 5 个:capture_error_marks_rewind_partial_and_keeps_timeline /
complete_rewind_marker_prunes_timeline /
chmod_between_preview_and_rewind_is_a_conflict /
mode_only_drift_previews_as_restore_and_is_restored /
write_new_file_through_internal_symlink_resolves_real_parent。

验证:cargo test --lib 800 passed;cargo check --tests 零错误;
pnpm -C crates/agent-gui build 通过;pnpm test:gui 1883 passed;
biome 对改动文件零新增;rustfmt --check 漂移数与基线持平。
@su-fen

su-fen commented Aug 16, 2026

Copy link
Copy Markdown
Member

复审时发现三个 P1 问题,均已确认存在并在 7d5351bc 修复(已推到本分支)。

P1-1:不完整检查点会被误判为完整回退

rewind_at 调用 earliest_records_since 时把 error 计数丢弃了(let (records, _errors) = ...),而 checkpoint_rewind_code_sync 的完整性判定只看 conflicts / failed / skipped_dirs。于是:某轮有大文件超限或捕获失败(只有 kind="error" 记录、没有前像)时,回退会"成功"并写下 turn_seq=target完整剪枝标记——没被恢复的文件仍停在改动后状态,但该轮连同 error 记录被剪枝永久藏掉,UI 显示一切正常。

修复:CheckpointRewindResult 新增 capture_errors;完整性判定收敛到 rewind_is_complete(),捕获缺口计入"没回退干净",只写 turn_seq=0 审计标记不剪枝。前端部分完成对话框与通知同步明示"N 个无前像未回退"。锁内的"回退+写标记"抽成可注入目录的 rewind_and_mark_at,新增两个测试钉住完整/部分标记的分界。

P1-2:chmod 绕过预览与冲突检测

current_state_hash 只哈希内容,但回退的"内容已一致"短路分支会调用 restore_file_mode 还原权限位。两个后果:确认框停留期间用户 chmod 的文件,TOCTOU 比对(纯内容哈希)照样通过,权限被静默改回捕获时的值;纯权限漂移(内容没变、+x 被改)在预览里显示为 clean,却会在回退时被"顺手"改掉——预览没披露、冲突检测拦不住。

修复:状态指纹改为 内容哈希@八进制mode(Windows 无 POSIX 位,退化为纯内容哈希,行为不变);预览分类用 mode_differs 把纯权限漂移判为 restore 如实披露;rewind 的 expected 比对自然连权限一起校验,预览后 chmod 判冲突跳过。新增 chmod_between_preview_and_rewind_is_a_conflict / mode_only_drift_previews_as_restore_and_is_restored

P1-3:经工作区内部符号链接新建的文件无法回退

fs_write_text 的 NotFound 分支直接以未解析的 raw_target 落盘并交给 checkpoint_rel——link/new.txt 这类经由内部目录符号链接的路径,strip_prefix 拿到的是链接形态的相对路径,检查点按它记录 existed_before=false。回退侧 resolve_rewind_target 逐级拒符号链接,这条"删除该文件"的记录永远判 unresolvable:新建的文件删不掉,回退不完整(而且在 P1-1 修复前还不影响完整标记,双重埋没)。已存在文件走 resolve_existing_file_target(canonicalize)没有这个问题,delete/edit 也都经 canonical 解析,只有"新建"这条路径漏了。

修复:ensure_parent_dir 返回 canonical 父目录,新建文件的落盘目标与检查点相对路径都基于真实路径(real/new.txt),与 existed_before 分支的解析语义对齐。新增 write_new_file_through_internal_symlink_resolves_real_parent

验证

  • cargo test --lib:800 passed / 0 failed(checkpoint 34 个,含新增 5 个)
  • cargo check --tests 零错误;rustfmt --check 漂移数与基线持平(24,均为既有)
  • pnpm -C crates/agent-gui build 通过;pnpm test:gui 1883 passed / 0 failed
  • biome 对改动文件与基线持平(19 warnings,无新增)

@xiaozhou26

Copy link
Copy Markdown
Contributor Author

三条 P1 我都回到 b1229aaf(修复前)逐行核过,全部真实存在,且三条都是我前几轮修复直接造成或直接漏掉的,不是误报。

复核结论

P1-1checkpoint.rs:1089 确实是 let (records, _errors) = earliest_records_since(...),error 计数当场丢弃;:1035complete 只看 conflicts / failed / skipped_dirs。配合 live_records:582out.retain(|r| r.turn_seq < record.turn_seq),写下 turn_seq=target 就把该轮连同 error 记录永久剪掉。

这条最难看:RECORD_CAP_ERROR_RESERVE 那条修复的全部理由就是"撞上限的轮次也要能写 error 记录,UI 才不会假装完好"。我为此专门留了 64 个尾部名额、写了注释解释为什么,然后在回退路径上把这些记录的唯一消费点丢了——保住了证据,又在用证据的地方扔了。

P1-2current_state_hash:828 只哈希内容,而我新加的 restore_file_mode 在"内容已一致"短路分支里照样执行。我加 mode 时只想着"回退要还原权限",没回头问"那 mode 要不要进状态指纹"。冲突检测的契约是"预览看到什么、回退就只动什么",我扩大了回退作用域却没同步扩大指纹。

P1-3fs.rs:3092 的 NotFound 分支 (raw_target.clone(), false) 用的是未解析路径。existed_before 分支走 resolve_existing_file_target(canonicalize),delete/edit 也都经 canonical 解析,只有"新建"这一条漏了。我的 checkpoint_rel 是在"resolve_target 已 canonicalize"的前提下写的,没意识到 write 的新建分支根本不走那个解析。

P1-3 和 P1-1 会叠成双重掩埋:新建文件删不掉 → 回退不完整 → 但在 P1-1 修好前,这个不完整连标记都不会体现,被完整剪枝直接藏掉。

共同的失误模式是:我改完一个点就验证"这个点对不对",没有回头问"下游消费者、对偶操作是不是也要跟着变"。

7d5351bc 的独立复核

修复方向我逐个核过,都对:rewind_is_complete()capture_errors 纳入判定并抽出 rewind_and_mark_at 保证锁内一致;指纹改 sha256@八进制modeensure_parent_dir 返回 canonical 父目录再拼文件名。

我另外查了一个提交说明里没提到的风险——指纹格式变更对已有 v2 记录、以及跨平台共享同一 checkpoint 目录的兼容性。结论是不成立:指纹从不写进 index.jsonlCheckpointRecord 的持久化字段只有 schema/turn_seq/turn_id/root/rel_path/kind/existed_before/blob/size/mtime_ms/captured_at/note/mode),只在"预览 → 确认 → 回退"一次交互内存活,两端必然是同一进程同一平台。老记录 mode: Nonemode_differs 返回 false,行为与改动前一致。

验证(含上次 CI 挂掉的盲区)

上一轮 CI 失败的根因是 cfg(unix) 测试在 Windows 上根本不编译,本地检查发现不了。这次我在 WSL Debian 装了 Linux 工具链,在 Linux 上真实跑了一遍:

  • Linux cargo test --lib:796 passed / 0 failed
  • Linux checkpoint 过滤:38 passed / 0 failed,含 Windows 编译不到的 7 个 cfg(unix) 测试,其中新增的 chmod_between_preview_and_rewind_is_a_conflictmode_only_drift_previews_as_restore_and_is_restored 均通过
  • Windows cargo check --tests 零错误(4 条既有 warning,均在 shell_runner.rs / managed_process.rs,与本 PR 无关)
  • pnpm -C crates/agent-gui build 通过;pnpm test:gui 5 个既有失败,与基线一致无新增
  • biome 对改动文件 19 warnings,与基线持平;git diff --check 干净
  • CI 在 7d5351bc 上全绿(run 31962961074)

@yovinchen 三条都已确认并修复,CI 已绿,麻烦有空时再看一下。

@su-fen

su-fen commented Aug 17, 2026

Copy link
Copy Markdown
Member

基于当前头部 56a777d0cb94dd43a491ebd2f5eb7d9bbbdfd515 做了新一轮只读复审。此前关于 schema v2、symlink/hardlink、完整性标记、mode 指纹、内部 symlink 新建路径、Windows 写入保护等修复均已看到;当前 main=89f5e2b0,merge-tree 无冲突,远端 8 项检查全绿,本地针对性验证也通过:checkpoint 38/38、subagent worktree 9/9git diff --check 通过。

不过目前仍不建议直接合入,剩余三个 P1:

P1-1:Root Grant 没有在后端执行时重新授权

CheckpointRewindMenu.resolveAuthorizedRoots() 在 diff 预览前读取一次授权根,确认框结束后继续复用同一个 authorizedRoots。额外授权只判断 grant.state === "active",没有要求 grant.access === "write";而后端 checkpoint_rewind_code 只接收并 canonicalize 调用方给出的 Vec<String>,不会按稳定 grant_id/project_id 查询数据库确认授权仍存在、路径身份未变、当前仍为 write。

因此确认框打开期间若 Grant 被撤销或从 write 降为 read,旧路径集合仍可执行回退写入。建议请求携带稳定 Project/Grant 身份,并在后端真正写入前查询当前授权,逐条确认 active + write + canonical identity;renderer 提供的路径不应充当最终授权凭据。同时补充 revoke/downgrade-during-confirm 测试。

P1-2:预览哈希校验与实际替换不是 CAS

rewind_atcheckpoint.rs:1159 比较当前内容/mode 指纹,但真正的 remove_file / atomic_write 分别发生在后续 :1190 / :1230。中间的 reverify_target() 只重新解析路径并比较 PathBuf,没有重新确认内容、mode、文件/父目录身份。

所以另一个进程可以在 hash 校验通过后、rename/delete 前修改文件,随后仍被旧前像覆盖;父目录也可在 reverify_target() 返回后被交换。临时文件 + rename 只保证完整写入,不提供 compare-and-swap。

建议使用稳定目录/文件句柄与 no-follow 语义,并把 expected content+mode+identity 校验绑定到最终替换/删除;至少增加“后端第一次 hash 校验之后发生并发写入”和“最终动作前父目录身份变化”的测试。

P1-3:Worktree checkpoint 没有绑定实际 apply 结果

subagent_worktree.rs:813 在任何 apply 尝试前对全部 apply_paths 捕获前像,随后才依次尝试普通 apply、3-way 和 file-copy fallback。fallback 最终可能是 already_appliedfallback_noop,也可能执行部分路径后失败,但此前所有路径的 checkpoint 已经提交。

这会留下本轮实际上没有修改过的幽灵记录,后续 rewind 可能恢复/删除无关文件。现有 subagent_worktree_apply_falls_back_when_file_is_already_present 没有携带 checkpoint,因此未覆盖该契约。

建议采用 pending capture,并只对实际成功修改的路径提交记录;noop/already-applied/失败路径应丢弃。补充 already_applied + checkpoint、fallback noop、部分失败及混合成功/冲突路径测试。

另外,GitHub 当前仍是 mergeStateStatus=BLOCKEDreviewDecision=CHANGES_REQUESTED,两次正式 Request Changes 尚未被当前头部的批准或显式处理解除。上述代码问题修复后,仍需 reviewer 对新头部重新正式确认。

结论:Git/CI/自动治理层面已干净,但授权、并发写入和 checkpoint 记账语义仍有阻断,因此当前不能直接合入。

@xiaozhou26

Copy link
Copy Markdown
Contributor Author

三条我都回到 56a777d0 逐行核过了。事实描述基本都成立,但其中两条的危害定性与实际代码行为有出入,先把证据摆出来,范围请你确认后我再动手。


P1-1 — 拆成两半,一半必修,一半超出本 PR

必修的一半:access === "write" 漏判,属实,是我的疏漏。

比基线更糟的地方在于不只是"两边都在前端"。useSendChatTurn.ts:353 同样只过滤 state === "active",但它access 保留着往下传:357),最终由 pathUtils.ts:421 拦住:

const canMutate = PROJECT_ROOT_MUTATION_INTENTS.has(options.intent) && root.access === "write";

CheckpointRewindMenu.tsx:109push(grant.canonicalPath) —— access 当场丢弃,后面没有任何地方能恢复它。基线存在的那道门,checkpoint 整个漏掉了,只读授权根会被当成可回退根。这条我修,与基线对齐。

超出本 PR 的一半:后端按 grant_id 查库重新授权。

"renderer 提供的路径不应充当最终授权凭据"这个批评我认同,但它适用于整个 fs 工具面,不是 checkpoint 特有:

  • fs_write_text(workdir: String, ...)fs.rs:3144)、fs_delete:3396)直接接收调用方给的 workdir,后端只做路径几何校验(canonicalize + starts_with
  • fs.rs 全文 grep grant 零命中
  • authorized_roots 这个参数模式全仓只有 checkpoint 三处在用

反过来,checkpoint_rewind_code 是全仓唯一会拒符号链接根(checkpoint.rs:760)、要求 canonicalize 后精确等于授权集合某项(:771,非前缀匹配)、逐级 symlink_metadata 拒符号链接(:796-808)、并 reject_multi_hardlink 的写入路径。约束强度高于 fs.rs 基线。

所以它不是绕过了本仓授权体系,而是在本仓最宽松的体系里做得最严、但仍差一道 write 位。要求后端查库重新授权是全仓授权模型改造,我建议单独开 issue,在本 PR 里做会造出两套不一致的授权语义。

「确认框期间 revoke 仍可写入」的窗口,与基线"轮内不复查"(additionalRoots 每轮开始快照一次)完全同构。


P1-2 — 事实成立,有一处描述需要更正,且是全仓通病

先更正:reverify_target:1247不是"只重新解析路径并比较 PathBuf"。它内部调 resolve_rewind_target,每次重跑完整授权链——重新 canonicalize 根、校验根在授权集合内、逐级拒符号链接。只是最终结果用 PathBuf 比较。父目录被换成符号链接这个场景,它是挡得住的。

核心指控站得住:hash 校验(:1159)与 remove_file:1190)/ atomic_write:1230)之间确实没有二次内容比对,atomic_write 是临时文件 + rename,只保证写入完整性,不是 CAS。

但对照 fs_write_text_implensure_expected_version_matchesfs.rs:3111)→ capture_pre_imagefs::write:3128)。同一个校验-写入窗口,而且连 checkpoint 侧那种紧邻动作的二次授权校验都没有。

这是全仓既有的写入语义,checkpoint 侧反而更严。 在本 PR 里单独给 checkpoint 上 no-follow 句柄绑定 + CAS,会造出与所有其他文件工具不一致的两套语义。建议单独 issue、统一处理。如果你认为必须在本 PR 内解决,请明确,我照做。


P1-3 — 事实全对,但"会恢复/删除无关文件"这个推论不成立

事实部分我全部确认:捕获点(subagent_worktree.rs:813)确在所有 apply 分支之前;capture_worktree_apply_pre_images 无返回值、无回滚补偿;already_applied / fallback_noop 分支真实存在;..._falls_back_when_file_is_already_present 第三参数确为 None,该文件 5 处调用全传 None

但危害推论要看 classify_entrycheckpoint.rs:883-905):

let action = if !record.existed_before {
    if hash == "absent" { "clean" } else { "delete" }
} else {
    // ...
    if sha256_hex(&expected) == current_content && !mode_differs(record.mode, &target) {
        "clean"
    } else { "restore" }
  • already_applied / fallback_noop:父仓库一个字节没动,捕到的前像就等于当前内容sha256 相等 → 判 clean,不写不删
  • existed_before=false 且文件没建成 → hash == "absent" → 判 clean
  • 部分成功后失败:那些路径确实被改了,前像记录反而是必要的,没有它才会丢失回退能力

我特意去找「existed_before=false 记录导致误删父仓库既有文件」这个最危险的场景,没找到subagent_worktree.rs:806-812 有注释表明这个陷阱当初被考虑过,捕获点被特意放在 empty_patch 提前返回之后正是为此。

所以幽灵记录的实际代价是 blob 占盘(计入 512MB 上限)+ UI 多出条目,不是错误回退。我认同补 already_applied + checkpoint 测试,但它的作用是锁定"判 clean"这个契约防止未来回归,而非修复数据损坏。


我自己核出来的一条,你没提到

already_applied 这种"父仓库实际什么都没发生"的轮次,如果 apply_paths 里含 symlink 或特殊文件,classify_worktree_pre_image 返回 Errrecord_capture_skip 写 error 记录(:509)→ UI 标 ⚠"捕获失败,回退可能不完整",且 rewind_is_complete()capture_errors > 0 判不完整。

这是我上一轮 P1-1 修复引入的误报:一个什么都没做的轮次被报成"回退不完整"。属于我该修的范围。


建议范围

判定 处理
P1-1 write 位漏判 真缺口,基线有门本 PR 漏了 本 PR 修(一行,与 pathUtils.ts:421 对齐)+ 补 read-only grant 测试
P1-1 后端查库授权 全仓架构问题 建议单独 issue
P1-2 非 CAS 窗口 属实,fs.rs 同样有且更宽 建议单独 issue 统一处理
P1-3 幽灵记录 属实但 rewind 判 clean,非数据损坏 本 PR 补测试锁定契约
noop 轮次 ⚠ 误报 我上轮引入 本 PR 修

@yovinchen 麻烦确认这个范围划分。如果你认为 P1-2 的 CAS 必须在本 PR 内解决(而非跟 fs.rs 一起统一改),或者对 P1-3 的 clean 判定有不同读法,请指出来,我按你的结论改。范围定了我一次性提交,避免来回。

@su-fen
su-fen self-requested a review August 17, 2026 04:11
回退是写操作(覆盖/删除),但授权根集合此前只过滤了 grant.state,把只读根
也一并交给了后端。普通文件工具靠 pathUtils 的 canMutate 拦这一层,而回退
只往后端传路径、access 当场就丢,所以这道门只能在取根这一步补。

另修上一轮 P1 引入的误报:worktree apply 的前像捕获发生在所有 apply 分支
之前,捕获缺口当场记 error 会让 already_applied / fallback_noop(父工作区
一个字节没动)的轮次在 UI 上标 ⚠"回退可能不完整"。改为攒着,只在确认
apply 真改动过父工作区的分支上落账。成功的前像仍立即落盘——过了那一行
就没得捕获了。

新增测试锁住 noop 轮次的回退契约:冗余记录一律判 clean,既不写回也不删除。
@xiaozhou26

Copy link
Copy Markdown
Contributor Author

已推 a02c0ce7,按上一条回复里划的范围落了改动。

P1-1(本 PR 内必修的部分)CheckpointRewindMenu.tsx 取授权根时补上 access === "write" 过滤。此前只过滤了 grant.state === "active",只读根也被当成可回退目标交给后端。回退是写操作,普通文件工具靠 pathUtils.ts:421canMutate 拦住只读根,而回退这条路只往后端传路径、access 当场就丢了,所以这道门只能在取根这一步补。这是本 PR 自己漏掉的基线门禁,跟"后端应不应该独立查授权库"是两件事——后者仍建议单开 issue(fs_write_text / fs_delete 同样只做几何校验,不是 checkpoint 引入的)。

顺带修了一个你没报、但确实存在的误报:worktree apply 的前像捕获发生在所有 apply 分支之前,捕获缺口当场记 error 会让 already_applied / fallback_noop(父工作区一个字节没动)的轮次在 UI 上标 ⚠"回退可能不完整"。这是我上一轮 P1-1 修复引入的。改为把缺口攒着,只在确认 apply 真改动过父工作区的分支上落账;成功的前像仍立即落盘——过了那一行就没得捕获了。

P1-3 复核结论没变:不构成阻断。classify_entrycheckpoint.rs:883-905)对 existed_before=false + hash == "absent" 判 clean,对内容指纹相等且无 mode 漂移也判 clean,所以 noop apply 留下的冗余记录既不会写回也不会删除。新增 worktree_noop_apply_records_rewind_as_clean 把这个契约锁死了——后续谁动了相等判定或捕获时机,冗余记录立刻会升级成"删掉用户没碰过的文件",测试会先红。

P1-2 维持上一条的判断:CAS 缺口真实存在,但 fs_write_text_implfs.rs:3111→3128)的 check-to-write 窗口相同且更宽(没有相邻的重新鉴权),是仓库级问题,建议单开 issue 统一上 CAS,不在本 PR 收口。另外 reverify_targetcheckpoint.rs:1247)不是单纯的 PathBuf 比较——它内部调 resolve_rewind_target,每次都重跑完整鉴权链(重新 canonicalize root、校验授权集合成员、逐级 symlink_metadata 拒绝符号链接)。

验证

  • Linux(WSL Debian,含 Windows 上编译不到的 #[cfg(unix)] 测试):cargo test --lib801 passed / 0 failed
  • Windows:checkpoint 32/32、subagent worktree 9/9;全量 765 passed / 12 failed,这 12 个在未打补丁的干净树上同样失败git::tests::git_*worktree* 8 个 + hook::tests::run_hook_script_sync_*),根因是本机 //?/ 长路径前缀 + tempdir 下 git 报 could not create leading directories ... Invalid argument,与本 PR 无关
  • pnpm -C crates/agent-gui build → 0;pnpm check:ui-boundaries → 通过;git diff --check → 通过

@su-fen
su-fen requested review from yovinchen and removed request for yovinchen August 17, 2026 04:45
@su-fen
su-fen merged commit c97cfe7 into Stack-Cairn:main Aug 17, 2026
8 checks passed
@xiaozhou26
xiaozhou26 deleted the feat/checkpoint-rewind branch August 17, 2026 09:56
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.

[Feature] 会话级文件改动检查点与代码回退(Checkpoint / Rewind)

3 participants