feat(desktop): garbage-collect redundant recovery branches to trash#6102
Conversation
Conflict recovery forks a copy of the transcript on every save conflict (esengine#5993). The triggers are fixed, but every fork that ever happened still sits in the session list until the user trashes it by hand — the "会话列表被污染" half of that report was never addressed. Add a conservative GC. agent.ReclaimableRecoveryBranches reclaims a branch only when EVERY condition holds, because a recovery branch exists precisely to prevent data loss: - meta says Recovered with a fork digest; - the transcript still matches that digest (never continued on — one follow-up turn disqualifies it permanently); - the parent (meta.ParentID) still exists and COVERS the branch content (equal digest, or branch is a compatible prefix — the same checks SaveRecoveryBranch uses to declare recovery unnecessary); - no runtime holds the branch's session lease; - it has been idle past a 24h grace period. The desktop sweeps every session directory it lists from, once at startup and every 6h, re-checking liveness (lease + open-in-tab) immediately before disposal and routing removal through DeleteSession — so a swept branch is moved to the recoverable session trash, never hard-deleted. Verified the coverage guard by reverting it: a diverged fork (content present nowhere else) then becomes reclaimable and the test fails.
Review catch: startup launches restoreOrBuildTabs on a goroutine and started the GC immediately after, so the first sweep could run against the pre-restore empty tab map. Two consequences: sessionOpenInAnyTab would judge every saved tab's session as closed (a recovery tab about to be restored could be swept to trash), and DeleteSession's tab-list persistence would overwrite desktop-tabs.json with the empty snapshot, wiping the user's saved tab layout. Add a tabsRestored gate: armed in startup before the restore goroutine launches, closed via defer when restoreOrBuildTabs finishes (both the saved-tabs and first-launch paths, and the panic-recover path). The GC's first sweep waits on the gate or ctx cancellation. markTabsRestored is idempotent; tabsRestoredSignal reports already-restored when the gate was never armed, so App instances built directly in tests keep working. Regression test forces the exact race: gate closed → sweep provably blocked and the branch untouched; restore lands the tab holding the branch and opens the gate → the sweep runs and skips the now-open branch.
|
Valid P1 — fixed in 89f52f1. Confirmed all three legs of the race: Fix: a Regression test Verification: full desktop suite green (38.6s), |
Summary
补上 #5993 的最后半截:冲突触发源已在 1.17.x 修复,但历史上产生的每一个恢复分支仍堆在会话列表里,只能手动清理(该 issue 明确抱怨的"会话列表被大量 recovery 文件污染")。
清理策略(保守优先——recovery 分支的存在意义就是防数据丢失,宁可漏收不可误删):
agent.ReclaimableRecoveryBranches仅当全部条件成立才判定可回收:Recovered且记录了 fork 时的内容摘要;meta.ParentID,同目录)仍存在且覆盖分支内容(摘要相等,或分支是父的前缀、允许兼容的 system 消息替换)——与SaveRecoveryBranch判定"无需恢复"用的是同一组检查,即该分支未保留任何独有内容;agent.SessionLeaseHeld,含本进程——SessionLeaseHeldByOtherRuntime对本进程故意返回 false,GC 需要更严的问法);处置方式:desktop 启动时 + 每 6 小时后台扫描所有会话目录(全局 + legacy 共享 + 每个已保存项目),处置前再次核查租约与"是否在某个 tab 打开"(扫描是快照,期间用户可能打开了分支),然后走完整的
DeleteSession流程——移入可恢复的会话回收站,绝不硬删;即使启发式对某个分支误判,用户也能从回收站找回。Verification
TestReclaimableRecoveryBranchesCollectsOnlyCoveredIdleForks(covered 回收 / diverged 保留 / 续聊过保留)与TestReclaimableRecoveryBranchesRespectsGraceLeaseAndMissingParent(宽限期内保留、租约持有保留并验证SessionLeaseHeld本进程语义、释放后回收、父缺失保留)。fixtures 通过真实的SaveRecoveryBranch冲突流程构造。TestRecoveryGCTrashesCoveredForkAndKeepsParent(回收进回收站、父会话不动、二次扫描零回收)与TestRecoveryGCSkipsBranchOpenInTab。go test ./internal/agent(19.7s)与完整 desktop 套件(40.8s)全绿;-race覆盖 GC/租约路径;go vet干净。Cache impact
Cache-impact: none - session file lifecycle only; no provider request, prompt, or tool schema changes.
Cache-guard: not applicable (no cache-sensitive files); suites above cover the change.
System-prompt-review: N/A
Refs #5993(收尾其"列表污染"部分), #6054.