Skip to content

fix(core): Persist file history snapshot updates#5057

Merged
wenshao merged 4 commits into
QwenLM:mainfrom
doudouOUC:fix/file-history-snapshot-persistence
Jun 13, 2026
Merged

fix(core): Persist file history snapshot updates#5057
wenshao merged 4 commits into
QwenLM:mainfrom
doudouOUC:fix/file-history-snapshot-persistence

Conversation

@doudouOUC

Copy link
Copy Markdown
Collaborator

What this PR does

This PR makes file-history snapshot updates durable during a turn by recording the latest snapshot immediately after a tracked edit actually adds or heals a backup. The change keeps the existing append-only file_history_snapshot record shape, preserves explicit turn-boundary recording, and documents why schemaVersion and isSnapshotUpdate are not needed.

Why it's needed

A session could previously exit after an edit/write tool captured file backups but before the next UserQuery makeSnapshot(), leaving resume without the last turn's updated file-history state. Persisting the mutated latest snapshot closes that last-turn /rewind gap while preserving backward compatibility and last-wins reconstruction by promptId.

Reviewer Test Plan

How to verify

Run cd packages/core && npx vitest run src/services/fileHistoryService.test.ts src/services/sessionService.test.ts src/services/chatRecordingService.test.ts src/core/client.test.ts src/config/config.test.ts and confirm all 543 focused core tests pass. Run cd packages/cli && npx vitest run src/acp-integration/session/Session.test.ts and confirm all 125 ACP session tests pass. Run npm run build && npm run typecheck and confirm it exits 0. Reviewers should also confirm that repeated snapshot records for the same promptId remain append-only and resume keeps the later record, that duplicate trackEdit calls for an already captured non-failed file do not record again, and that recorder failures remain best-effort and do not block edit/write behavior.

Evidence (Before & After)

Before: if the process exited after a tool edit but before another prompt created the next snapshot, resume could reconstruct only the turn-boundary snapshot and lose the edited turn's file-history state. After: the updated latest snapshot is recorded immediately after trackEdit mutates it, and focused tests cover snapshot update recording, JSONL serialization, resume last-wins reconstruction, client prompt flow, ACP prompt flow, and recorder error handling. Manual TUI E2E evidence is not included; an E2E plan is added for the resume plus /rewind scenario.

Tested on

OS Status
🍏 macOS ✅ tested
🪟 Windows ⚠️ not tested
🐧 Linux ⚠️ not tested

Environment (optional)

macOS local checkout, Node.js v22.22.3, npm 10.9.8. npm run build && npm run typecheck exits 0 with existing warnings from the VS Code companion curly lint rules plus Browserslist/chunk-size warnings.

Risk & Scope

  • Main risk or tradeoff: Recording updated snapshots after edit tracking adds more append-only system records in sessions with multiple tracked file edits, but it avoids rewriting logs and keeps the existing resume deduplication model.
  • Not validated / out of scope: Manual TUI E2E was not executed in this pass. Generic shell edit tracking, simulated sed -i coverage, getDiffStats concurrency limiting, and per-file failure reasons remain out of scope.
  • Breaking changes / migration notes: No breaking changes and no migration are required because the persisted file_history_snapshot payload shape is unchanged.

Linked Issues

Refs #4204

中文说明

这个 PR 做了什么

这个 PR 让单轮内的 file-history snapshot 更新变成持久化:当被跟踪的编辑真正新增备份或修复 failed 备份后,立即记录最新 snapshot。改动保持现有追加式 file_history_snapshot 记录形态,保留显式的 turn-boundary 记录,并说明为什么不需要 schemaVersionisSnapshotUpdate

为什么需要

之前会话可能在 edit/write 工具捕获文件备份之后、下一次 UserQuery makeSnapshot() 之前退出,导致 resume 时缺少最后一轮更新后的 file-history 状态。持久化被修改过的最新 snapshot 可以补上这个 last-turn /rewind 缺口,同时保持向后兼容,并继续使用按 promptId last-wins 的恢复模型。

Reviewer Test Plan

How to verify

运行 cd packages/core && npx vitest run src/services/fileHistoryService.test.ts src/services/sessionService.test.ts src/services/chatRecordingService.test.ts src/core/client.test.ts src/config/config.test.ts,确认 543 个 core focused tests 全部通过。运行 cd packages/cli && npx vitest run src/acp-integration/session/Session.test.ts,确认 125 个 ACP session tests 全部通过。运行 npm run build && npm run typecheck,确认退出码为 0。Reviewer 还应确认同一个 promptId 的重复 snapshot 记录仍然是 append-only 且 resume 保留后写入记录,已捕获且非 failed 文件的重复 trackEdit 不会再次记录,并且 recorder 失败仍保持 best-effort,不阻塞 edit/write 行为。

Evidence (Before & After)

Before:如果进程在工具编辑后、下一次 prompt 创建新 snapshot 前退出,resume 可能只能重建 turn-boundary snapshot,从而丢失该编辑轮的 file-history 状态。After:trackEdit 修改最新 snapshot 后会立即记录更新后的 snapshot,focused tests 覆盖 snapshot update recording、JSONL 序列化、resume last-wins 重建、client prompt flow、ACP prompt flow 和 recorder 错误处理。本 PR 未包含手动 TUI E2E 证据;已为 resume 加 /rewind 场景补充 E2E 计划。

Tested on

OS Status
🍏 macOS ✅ tested
🪟 Windows ⚠️ not tested
🐧 Linux ⚠️ not tested

Environment (optional)

macOS 本地 checkout,Node.js v22.22.3,npm 10.9.8。npm run build && npm run typecheck 退出码为 0,同时保留既有的 VS Code companion curly lint warnings 以及 Browserslist/chunk-size warnings。

Risk & Scope

  • Main risk or tradeoff:在包含多个被跟踪文件编辑的会话中,编辑后记录更新 snapshot 会增加追加式 system records 数量,但这避免了重写日志,并保持现有 resume 去重模型。
  • Not validated / out of scope:本轮未执行手动 TUI E2E。通用 shell 编辑跟踪、模拟 sed -i 覆盖、getDiffStats 并发限制和 per-file failure reasons 仍不在范围内。
  • Breaking changes / migration notes:没有 breaking changes,也不需要迁移,因为持久化的 file_history_snapshot payload 形态保持不变。

Linked Issues

关联 #4204

@doudouOUC
doudouOUC marked this pull request as ready for review June 12, 2026 17:34
Copilot AI review requested due to automatic review settings June 12, 2026 17:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Comment thread packages/core/src/services/fileHistoryService.ts
Comment thread .gitignore Outdated
Comment thread packages/core/src/config/config.ts
Comment thread packages/core/src/config/config.ts
Comment thread packages/core/src/services/fileHistoryService.ts
Comment thread packages/core/src/services/fileHistoryService.ts
Comment thread packages/core/src/services/fileHistoryService.test.ts Outdated
Comment thread packages/core/src/services/chatRecordingService.test.ts
@doudouOUC doudouOUC self-assigned this Jun 13, 2026

@wenshao wenshao 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.

[Critical] resolveBackupPath (fileHistoryService.ts:210) passes backupFileName directly to path.join(baseDir, sessionId, backupFileName) with zero path-traversal validation. backupFileName is deserialized from the JSONL session file in deserializeSnapshots (line 155) with no sanitization — it is accepted as-is. An attacker with write access to the JSONL session file can craft backupFileName: "../../../../../../etc/hosts" so the next /rewind copies that file over the user's workspace file via safeCopyFile. Pre-existing, but this PR increases the persistence frequency of file_history_snapshot records, making the attack surface proportionally more active.

function resolveBackupPath(backupFileName: string, sessionId: string): string {
  const base = join(Storage.getGlobalQwenDir(), FILE_HISTORY_DIR, sessionId);
  const resolved = join(base, backupFileName);
  if (!resolved.startsWith(base + sep) && resolved !== base) {
    throw new Error(`backupFileName escapes base directory: ${backupFileName}`);
  }
  return resolved;
}

— qwen3.7-max via Qwen Code /review


[Critical] startNewSession calls finalize() on the old ChatRecordingService but does NOT await flush(). finalize() now queues pending snapshot writes onto the async writeChain via flushPendingFileHistorySnapshots(). The service is immediately replaced (line 2253), orphaning pending writes. The other two callers (shutdown() at line 2851 and prepareSessionArtifactMigration() at line 2732) correctly pair finalize() with await flush().

On /clear, /new, or /reset, file-history snapshots from the previous session can be silently lost.

try {
  this.chatRecordingService?.finalize();
  await this.chatRecordingService?.flush();
} catch {
  // Best-effort — don't block session switch
}

— qwen3.7-max via Qwen Code /review

Comment thread packages/core/src/services/chatRecordingService.ts
Comment thread packages/core/src/services/chatRecordingService.ts Outdated
Comment thread packages/core/src/services/chatRecordingService.ts Outdated
Comment thread packages/core/src/services/chatRecordingService.ts
Comment thread packages/core/src/services/fileHistoryService.ts
Comment thread packages/core/src/services/fileHistoryService.ts
Comment thread packages/core/src/config/config.ts
Comment thread packages/core/src/services/fileHistoryService.ts Outdated
Comment thread packages/core/src/services/chatRecordingService.ts Outdated

@qqqys qqqys 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.

Prior critical feedback appears resolved on head 47fabf6: backup paths are now constrained to the session backup directory, stale callbacks are covered, and file-history snapshot writes are no longer deferred behind the session switch. I did not find a new critical blocker in this pass.

@doudouOUC
doudouOUC requested a review from wenshao June 13, 2026 11:36
@wenshao

wenshao commented Jun 13, 2026

Copy link
Copy Markdown
Collaborator

✅ Verification report — local runtime A/B (Linux)

I verified this PR by building the actual post-merge tree (origin/main + this branch — GitHub reports MERGEABLE, git merge-tree is clean) and running a real BEFORE/AFTER comparison, not just the unit suites. The last-turn /rewind gap described in the PR is real, and this change closes it. No PR-introduced regressions found; the security hardening in resolveBackupPath is a welcome bonus.

Environment: Linux, Node v22.22.2. BEFORE = origin/main; AFTER = merge of origin/main + this branch. Both arms run the same cli build; only the four core source files differ, so the delta is exactly this PR.

1. Claimed suites + typecheck (AFTER / merged tree)

Check Result
core: fileHistoryService + sessionService + chatRecordingService + client + config 558 passed (PR said 543; the merge with newer main grew the suites — all green)
cli: acp-integration/session/Session.test.ts 125 passed (matches the PR)
packages/core tsc --noEmit exit 0
packages/cli tsc --noEmit exit 0

(The full npm run build emits the usual TS5055 … would overwrite input noise from the project-reference dist in an ephemeral worktree — environmental, not from this PR. The per-package tsc --noEmit typecheck is clean on both touched packages.)

2. Revert-proof — the tests pin the new behavior

Reverting only the 4 source files to origin/main (keeping this PR's tests) makes exactly 9 tests fail (549 pass), all on the behavioral core of the change:

fileHistoryService.test.ts
  × trackEdit > records the updated latest snapshot after tracking a file
  × trackEdit > does not record duplicate tracking for the same file
  × trackEdit > records again when a second file is tracked in the same snapshot
  × trackEdit > swallows recorder errors after tracking a file
  × trackEdit > heals a failed entry on the next trackEdit attempt
  × restoreFromSnapshots > records failed markers when restored backup files are missing
  × restoreFromSnapshots > does not restore backup files that escape the session directory
config.test.ts
  × wires file history snapshot updates to chat recording
  × startNewSession > flushes the outgoing chat recording service when switching sessions

The reverted source also fails to even typecheck the new tests (TS2554: Expected 3 arguments, but got 4 — the new onSnapshotUpdated recorder arg). The new client.test.ts / chatRecordingService.test.ts cases pass on both arms, which is consistent with the diff: client.ts is a comment-only change (the makeSnapshot + turn-boundary record already existed on main), and the recordFileHistorySnapshot refactor is behavior-preserving.

3. Service-level A/B on the shipped artifact

Imported the built core/dist/.../fileHistoryService.js and ran makeSnapshot('p1')trackEdit(file) against a real file, then simulated resume (serialize → last-wins-by-promptIdrestoreFromSnapshotsrewind('p1')):

recordedCount recorded snapshot has backup resumes + rewinds to original
AFTER 1 a.txt backup v1 ✅ file → ORIGINAL-CONTENT
BEFORE 0 ❌ nothing persisted ❌ file stuck at MODIFIED-CONTENT

The turn-boundary snapshot (what client.ts records right after makeSnapshot) is empty on both arms — so BEFORE the fix the only persisted record for that turn carries no backup, and a resume can't rewind the edit.

4. Real TUI E2E (tmux) — the headline

Drove the real qwen TUI in tmux against a scripted mock provider that performs a genuine read_filewrite_file tool chain (--yolo), so the app actually edits a.txt. One prompt, then a clean /quit (no second prompt) — i.e. the exact "session exits after an edit but before the next makeSnapshot" scenario. Inspecting the persisted session JSONL:

AFTER  →  file_history_snapshot records: 2
  fhs#0 promptId=…########0 files=[]                          ← turn-boundary (empty)
  fhs#1 promptId=…########0 files=[a.txt: backup=SET / v1]    ← trackEdit-recorded (THE FIX)

BEFORE →  file_history_snapshot records: 1
  fhs#0 promptId=…########0 files=[]                          ← only the empty record; backup lost

Same single edit, same clean exit — AFTER persists the pre-edit backup immediately (so resume + last-turn /rewind works), BEFORE persists only the empty snapshot. This matches the service-level A/B exactly.

Notes (non-blocking)

  • Append semantics: records are appended through an async write chain; a graceful exit flushes them (verified). The fix queues the backup record immediately on trackEdit, closing the gap for normal exits; it does not make individual appends synchronous, so a hard SIGKILL landing between "queued" and "written" remains best-effort — acceptable and an inherent property of the append model, not a regression.
  • Bonus hardening: resolveBackupPath now rejects a backupFileName that escapes the session backup dir (path-traversal guard), covered by the new "does not restore backup files that escape the session directory" test. Good addition.
  • Scope: I exercised the GeminiClient/TUI path (TTY → file checkpointing enabled). The daemon/ACP path (Session.ts) drives makeSnapshot directly and was not separately E2E'd here, but its makeSnapshot('p1') call site mirrors the same pattern and the 125 ACP Session.test.ts cases pass.

Verdict: LGTM. The fix is correct, tested, regression-free on the merge result, and the BEFORE/AFTER behavior is exactly as described.

中文说明

✅ 验证报告 — 本地真实运行 A/B(Linux)

我在真正的合并后代码树(origin/main + 本分支,GitHub 显示 MERGEABLE,git merge-tree 干净)上构建并做了真实的 BEFORE/AFTER 对比,而不仅仅跑单测。PR 描述的"最后一轮 /rewind 丢失"问题确实存在,本次改动把它修好了。 没有发现 PR 引入的回归;resolveBackupPath 里顺带加的路径穿越防护是加分项。

环境:Linux,Node v22.22.2。BEFORE = origin/main;AFTER = origin/main + 本分支的合并结果。两侧使用同一个 cli 构建,只有 4 个 core 源文件不同,所以差异恰好就是本 PR。

1. PR 声称的测试 + 类型检查(AFTER / 合并树)

  • core 五个聚焦套件:558 通过(PR 写的是 543;与较新的 main 合并后套件变多 —— 全绿)。
  • cli Session.test.ts:125 通过(与 PR 一致)。
  • packages/corepackages/clitsc --noEmit 均退出 0
  • (完整 npm run build 在临时 worktree 里会有 TS5055 … would overwrite input 噪音,属于 project-reference dist 的环境问题,与本 PR 无关;真正的 tsc --noEmit 类型检查在两个被改包上都是干净的。)

2. Revert 反证 —— 测试确实锁定了新行为
只把 4 个源文件回退到 origin/main(保留本 PR 的测试),恰好 9 个测试失败(549 通过),全部命中改动的行为核心:trackEdit 立即记录(×5)、restoreFromSnapshots 记录 failed 标记、路径穿越防护("does not restore backup files that escape the session directory")、以及 config 的接线 + 切换 session 时 flush(×2)。回退后的源码甚至无法通过新测试的类型检查(TS2554: Expected 3 arguments, but got 4 —— 新增的 onSnapshotUpdated 参数)。新增的 client.test.ts / chatRecordingService.test.ts 在两侧都通过,这与 diff 一致:client.ts 只是注释改动(makeSnapshot + turn-boundary 记录在 main 上早已存在),recordFileHistorySnapshot 的重构是行为等价的。

3. 对已构建产物的 service 级 A/B
直接 import 已编译的 core/dist/.../fileHistoryService.js,对真实文件执行 makeSnapshot('p1')trackEdit(file),再模拟 resume(序列化 → 按 promptId last-wins → restoreFromSnapshotsrewind('p1')):

  • AFTER:recordedCount=1,记录的 snapshot 带 backup ✅,resume 后 rewind 把文件还原成 ORIGINAL-CONTENT ✅。
  • BEFORE:recordedCount=0,什么都没持久化 ❌,文件停在 MODIFIED-CONTENT ❌。
    两侧的 turn-boundary snapshot(client.tsmakeSnapshot 之后立刻记录的那条)都是空的,所以修复前那一轮唯一被持久化的记录里没有 backup,resume 后无法回退该编辑。

4. 真实 TUI 端到端(tmux)—— 重点
在 tmux 里驱动真实的 qwen TUI,用脚本化 mock provider 触发真正的 read_filewrite_file 工具链(--yolo),让 app 真的去改 a.txt只发一次 prompt,然后干净 /quit(不发第二次)—— 正是"编辑后、下一次 makeSnapshot 前退出"的场景。检查持久化的 session JSONL:

  • AFTER:2 条 file_history_snapshot —— fhs#0 空(turn-boundary)+ fhs#1a.txt: backup=SET / v1(即修复本身)
  • BEFORE:只有 1 条 —— fhs#0 空,backup 丢失。
    同一次编辑、同样干净退出:AFTER 在 trackEdit 后立即持久化了 pre-edit backup(resume + 最后一轮 /rewind 可用),BEFORE 只持久化了空 snapshot。与 service 级 A/B 完全吻合。

说明(非阻塞)

  • 追加语义:记录通过异步写链追加;优雅退出会 flush(已验证)。修复在 trackEdit立即入队该 backup 记录,从而堵上正常退出场景的缺口;它并没有把单条 append 改成同步写,所以恰好在"入队"和"落盘"之间发生硬 SIGKILL 仍是 best-effort —— 这是 append 模型固有的性质,可接受,不算回归。
  • 加分:resolveBackupPath 现在会拒绝逃逸出 session 备份目录的 backupFileName(路径穿越防护),并有对应新测试覆盖。
  • 范围:我覆盖的是 GeminiClient/TUI 路径(TTY → 文件 checkpointing 开启)。daemon/ACP 路径(Session.ts)直接调用 makeSnapshot,本次没单独做 E2E,但其调用点与该模式一致,且 125 个 ACP Session.test.ts 全通过。

结论:LGTM。 修复正确、有测试、在合并结果上无回归,BEFORE/AFTER 行为与描述完全一致。

Verified on Linux with a real TUI + mock-provider tool-edit E2E and built-artifact A/B; complements the author's macOS run.

@wenshao
wenshao merged commit 24a9828 into QwenLM:main Jun 13, 2026
28 checks passed
doudouOUC added a commit that referenced this pull request Jun 15, 2026
* fix(core): persist file history snapshot updates

Co-authored-by: Qwen-Coder <[email protected]>

* codex: address PR review feedback (#5057)

Co-authored-by: Qwen-Coder <[email protected]>

* fix(core): Address file history persistence review feedback

Co-authored-by: Qwen-Coder <[email protected]>

* fix(core): Address file history review follow-ups

Co-authored-by: Qwen-Coder <[email protected]>

---------

Co-authored-by: Qwen-Coder <[email protected]>
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.

4 participants