Qwen Code 文件历史快照持久化:/rewind跨会话恢复的 A+C 缺口闭环方案
【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code
导读
本文深入解析 Qwen Code 中/rewind文件历史(file history)快照的持久化设计:当一次工具编辑发生在回合边界makeSnapshot()之后、进程退出之前时,如何保证该编辑产生的快照状态在会话恢复(resume)后依然存在,从而让/rewind能把文件恢复到编辑前状态。文章以 docs/design/2026-06-13-file-history-snapshot-persistence.md 为骨架,结合FileHistoryService、SessionFileHistoryAccumulator等源码实现与对应 E2E 计划,讲清记录时机、持久化数据形状、last-wins 去重重建逻辑与兼容性边界。读完本文,你将掌握 Qwen Code 文件历史快照的完整生命周期:捕获、追加、持久化、恢复重建与回滚。
背景:/rewind文件历史的两类持久化缺口
Qwen Code 的/rewind依赖FileHistoryService在每个回合边界(turn boundary)对已跟踪文件生成快照,用于把工作区回滚到指定回合开始前的状态。该能力在 fileHistoryService.ts 中实现,其类注释明确了跟踪范围(与上游 claude-code 对齐):只有通过edit和write_file工具触发的文件修改会被跟踪,而经由run_shell_command(如sed -i、cp、mv、rm、npm脚本、git apply)或手工编辑造成的变更不会被捕获,/rewind也无法恢复。
在设计本次持久化方案之前,存在两个典型的持久化缺口(即设计文档所称的 "A+C gaps"):
- A(Append,追加缺口):
makeSnapshot(promptId)只在回合边界创建快照,但最后一个回合中通过edit/write_file完成的编辑发生在该回合快照生成之后、进程退出之前,此时没有任何机制把这个"最后一刻的编辑"记录进持久化日志; - C(Continue,继续缺口):会话恢复时,若快照记录没有正确落盘,
/rewind在 resume 后会丢失文件历史状态,无法对恢复前的编辑进行回滚。
本文要解决的正是"编辑完成 → 进程退出 → 会话恢复 →/rewind回滚"这条完整链路中的记录与重建问题,且不改动已持久化的 JSONL 数据模式。
核心设计:append-only 系统记录 + last-wins 去重
设计文档给出的总体思路非常简洁:file_history_snapshot记录保持追加式(append-only)系统记录不变。会话恢复时,通过线性读取历史中的所有快照记录,按promptId去重并采用last-wins(后写覆盖)语义重建文件历史。
这意味着:同一个promptId的更新版快照可以在稍后被追加到日志末尾,无需重写任何旧日志。旧日志中的快照条目继续有效,新增的更新条目只是"后来者居上",这一性质是方案无需 schema 迁移、无需专门 update 标志的根基。
快照更新记录:makeSnapshot仍显式记录,缺失的最后一回合交给 recorder
makeSnapshot(promptId)依旧负责创建回合边界快照,调用方也依旧显式记录它。被补上的"最后一回合"缺失场景,通过给FileHistoryService增加一个可选 recorder 回调实现:
- 当
trackEdit(filePath)成功向最新快照添加一个新的备份条目时; - 或者"治愈"(heal)了该快照中一条此前失败的备份条目时;
都会用更新后的快照调用 recorder,从而把最新状态追加进持久化日志。
从源码看,该回调在 fileHistoryService.ts 中以构造函数可选参数onSnapshotUpdated?: FileHistorySnapshotRecorder注入,recordSnapshotUpdate内部用 try/catch 包裹调用——recorder 的任何异常都会被吞掉并记入调试日志。设计文档明确要求:文件编辑必须保持 best-effort(尽力而为),文件历史持久化绝不能导致 edit/write 工具失败。
重复trackEdit不会重复记录
设计文档强调了一个去重细节:对**已经成功捕获且未失败(non-failed)**的文件再次调用trackEdit,由于快照本身没有变化,不会再次触发记录。对应实现位于 trackEdit:
const existing = mostRecent.trackedFileBackups[trackingPath]; // 仅当已存在确认成功(非 failed)的备份时才跳过 if (existing && !existing.failed) { return; }而如果已有条目带failed标记(说明makeSnapshot阶段对该文件的备份尝试抛过异常),则允许trackEdit重试:这是下一次捕获该文件"编辑前状态"的机会,避免 failed 标记永久残留、持续毒化该文件的 rewind 能力。
持久化数据形状与向后兼容
设计文档明确:不增加 schema version,不引入isSnapshotUpdate标志。现有 payload 结构已足够支撑向后兼容的重建:
{ "type": "system", "subtype": "file_history_snapshot", "systemPayload": { "snapshots": [] } }该形状在 chatRecordingService.ts 中定义为FileHistorySnapshotRecordPayload,其snapshots数组元素是序列化后的快照(promptId、timestamp、trackedFileBackups),序列化/反序列化逻辑在 fileHistoryService.ts 中成对实现,时间字段统一转为 ISO 字符串。
为什么不需要isSnapshotUpdate
设计文档的论证很关键:向日志追加一条带相同promptId的file_history_snapshot记录,与显式标记"这是一次更新"在效果上完全等价,因为SessionService.loadSession()在重建时本就按promptId做 last-wins 去重。因此新增标志属于冗余设计,被明确否决。
旧日志与畸形记录的处理
兼容性规则同样清晰:
- 没有这些记录的旧日志:恢复后文件历史状态为空(不报错、不崩溃),
/rewind自然无快照可回滚; - 畸形快照记录:跳过并给出警告,后续合法记录仍然可用,不会被一颗"坏苹果"拖垮整条链路。
这一"宽容读、严格写"的策略在 sessionService.ts 的恢复路径中有直接体现:loadSession遍历消息逐条喂给SessionFileHistoryAccumulator.add(),任何单条解析异常都被 try/catch 捕获并debugLogger.warn,不影响整体恢复。
源码纵深:FileHistoryService的实现细节
备份文件的命名与存储布局
备份文件并不内嵌在 JSONL 中,而是落在全局配置目录下独立的file-history子目录,按会话隔离(见 fileHistoryService.ts):
const baseDir = resolve(Storage.getGlobalQwenDir(), FILE_HISTORY_DIR, sessionId);文件名由文件路径的 SHA-256 哈希前 16 位十六进制拼接版本号生成:<sha256前16位>@v<version>。resolveBackupPath还做了路径逃逸防护:解析后的路径必须位于会话基目录之内,否则抛错拒绝——这是对异常backupFileName的防御性校验。
makeSnapshot:继承优化、failed 标记与数量上限
makeSnapshot(fileHistoryService.ts)的实现有几个值得注意的设计决策:
- 未变更文件复用旧备份:通过
checkOriginFileChanged对比文件 mode、size、mtime 与内容哈希,未变化的文件直接继承上一个快照的备份条目,避免产生冗余备份文件; - failed 条目绝不继承:若上一个快照中某文件的备份尝试失败(
failed: true),本次必须重新createBackup重试,而不是把失败标记一路拷贝下去——否则文件一旦长期不变,rewind 会被永久毒化; - 失败也要诚实记录:单文件备份抛错时,仍写入一条
failed: true的占位条目(携带上一个备份文件名),让 rewind/diff 通过filesFailed暴露失败路径,而非静默地把过期内容当成本回合状态恢复; - 快照数量上限:
MAX_SNAPSHOTS = 100(fileHistoryService.ts),超出后丢弃最旧快照并清理孤儿备份文件。
trackEdit:捕获编辑前状态的最后机会
trackEdit在 edit/write 工具完成编辑时被调用,其语义是"把当前工作区状态作为最新快照中该文件的备份记录下来"(即编辑前的旧内容)。它写入的是mostRecent(快照数组最后一个)——这也解释了设计文档的缺口:如果makeSnapshot之后没有新的回合边界,trackEdit更新的是同一个快照,必须通过 recorder 把变化落盘。
会话恢复链路:SessionFileHistoryAccumulator与 loadSession
恢复重建的"last-wins"核心逻辑封装在 session-file-history-state.ts 的SessionFileHistoryAccumulator中:
- 只接受
type === 'system' && subtype === 'file_history_snapshot'且systemPayload.snapshots为数组的记录,其余一律忽略; - 首次见到的
promptId追加到retainedPromptIds(保序),已见过的promptId则原地覆盖为最新快照——这就是 last-wins; - 保留条数同样受
MAX_SNAPSHOTS约束,超出时淘汰最旧promptId; finish()按保留顺序返回快照数组,无快照时返回undefined(对应"旧日志恢复后无文件历史"的语义)。
SessionService.loadSession()则在完整遍历消息、得到conversation后调用该累加器,把结果以fileHistorySnapshots字段返回给上层(sessionService.ts),供恢复后的会话重建FileHistoryService状态(restoreFromSnapshots还会做路径规范化与validateRestoredSnapshots的备份存在性校验)。
验证:E2E 场景与单元测试覆盖
仓库中的 E2E 计划(.qwen/e2e-tests/2026-06-13-file-history-snapshot-persistence.md)给出了完整的手工验证路径,核心场景正是本文开头的缺口:
- 开启文件 checkpoint 与聊天记录(chat recording);
- 在临时项目中启动交互会话;
- 通过正常 edit/write 工具路径让模型修改/写入文件;
- 编辑完成后立即退出(不再发送下一条 prompt);
- 恢复同一会话;
- 执行
/rewind回到触发该编辑的 prompt。
预期结果要求:恢复后的会话包含该回合更新过的file_history_snapshot记录;/rewind能恢复文件到编辑前状态;记录形状保持system类型 +file_history_snapshot子类型 +systemPayload.snapshots数组;不要求schemaVersion或isSnapshotUpdate字段。
验证命令(构建本地 CLI 后在一次性项目中跑):
npm run build && npm run bundle REPO_ROOT="<qwen-code 仓库根路径>" TMP_PROJECT="$(mktemp -d)" cd "$TMP_PROJECT" printf 'before\n' > a.txt node "$REPO_ROOT/dist/cli.js" --chat-recording进入 TUI 后让模型把a.txt中的before替换为after,在编辑工具完成后立即退出,用同一构建恢复会话并运行/rewind。该 E2E 计划注明"未在本次实现阶段执行",回归由聚焦的单元测试覆盖,包括:快照记录(sessionRecording)、JSONL 持久化(chatRecordingService.test.ts)、恢复重建(sessionService.test.ts、session-file-history-state.test.ts)、客户端 prompt 流程与 ACP prompt 流程。
范围界定:A+C only 与后续工作
设计文档明确本次改动仅覆盖 A+C,以下内容刻意不在本次范围内(与 claude-code 的能力对齐,避免过度实现):
- B1 模拟
sed -i覆盖:留给单独的 PR; - 通用 shell 编辑跟踪、
getDiffStats并发限制、单文件失败原因细化——均推迟。
因此,本文描述的持久化能力当前只对edit/write_file工具路径生效;通过 shell 命令改动的文件不会出现在快照中,/rewind也无法恢复,这是使用该功能时需要注意的边界。
兼容性与迁移
由于持久化记录形状完全不变:
- 无需任何迁移:升级到包含本改动的版本后,既有会话日志直接可用;
- 新版本只会额外追加
file_history_snapshot更新记录,不会改写历史行; - 回滚到旧版本也不会破坏新写入的日志——多出的系统记录在旧版恢复逻辑中要么被忽略,要么按既有快照规则处理。
小结
Qwen Code 通过"append-only 系统记录 + 按promptIdlast-wins 去重"这一最小侵入方案,闭环了/rewind文件历史在"编辑后立即退出"场景下的持久化缺口:FileHistoryService在trackEdit变更最新快照时通过可选 recorder 回调追加更新记录,SessionFileHistoryAccumulator在恢复时线性重建并按 promptId 去重,全程不改 schema、不增标志、不需迁移。结合 fileHistoryService.ts、session-file-history-state.ts 与 sessionService.ts 的实现,开发者可以完整追踪一条快照从捕获、落盘到恢复重建、回滚的全生命周期。
【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考