diff --git a/docs/conversation_load_unification_plan.md b/docs/conversation_load_unification_plan.md index 8e28eb96..b0d3cae0 100644 --- a/docs/conversation_load_unification_plan.md +++ b/docs/conversation_load_unification_plan.md @@ -160,6 +160,43 @@ enterConversation(convId, { source: 'refresh' | 'sidebar' }) | needs_rebuild 误判导致内容重复或缺失 | 判据移植自前端现逻辑,语义不变;误判方向与现状一致(现状同样可能误判) | | 回滚 | A、B 各为独立 commit,可分别 revert;前端保留 `restoreTaskState` 原路径代码注释标记,便于快速还原 | -## 7. 第三步(方向 C)展望 +## 7. 第三步(方向 C):合并写(C-merge) -A+B 稳定后评估:内存 `conversation_history` 降级为文件缓存,所有写入路径直写文件(或 WAL),chat task 主循环与 auto_save 合并为单写点,从根上消灭"多实例内存副本"问题。届时本计划的 A1 守卫可降级为断言,A3 调试代码移除。 +> 2026-07-20 调研定稿,用户拍板"先 merge 后评估 full"。A+B 已上线并人工回归通过。 + +### 7.1 调研结论(决定方案形态) + +- **"写入即落盘"现状已达成**:`add_conversation`(唯一消息追加入口)append 后立即同步全量落盘,无防抖。回退根源不是落盘时机,而是"多实例都能全量覆写"。 +- 消息追加仅 1 入口(`message_mixin.add_conversation`);整体替换仅 4 处(加载/新建/浅压缩 in-place/清空)。 +- 每条消息有稳定 `message_id`(`msg_`,存量抽查 100% 覆盖)→ 合并键可靠。 +- 合法"覆写语义"仅两处:检查点恢复(已豁免);浅压缩是 in-place 打标不缩减、手动压缩是新建对话——均不需要缩减豁免。 + +### 7.2 方案:merge-on-save(合并写代替覆写) + +`crud_mixin.save_conversation` 中 `existing_data["messages"] = messages` 全量覆写改为按 `message_id` 合并: + +1. **快路径**(正常追加,99%):内存 id 序列前缀包含磁盘 id 序列 → 直接用内存版(与现状一致)。 +2. **慢路径**(旧实例写回/分叉):以磁盘为基,同 id 消息取内存版(浅压缩打标等修改生效),内存独有 id 消息按原序追加在后 → 任何实例任何时机写回都不丢消息,回退从根免疫。 +3. **豁免路径**(`allow_shrink=True`,仅检查点恢复):保持覆写语义。 +4. A1 守卫演化为对 merged 的断言(恒不缩减,触发即 bug);**缩减写回不再拒绝而是矫正**(磁盘不动 + 旧实例独有消息追加救回),优于 A 的纯拒绝。 +5. 慢路径实质矫正发生时打印 `🔀 [ConvSaveMerge]` 醒目日志(观察期信号;无 id 消息防御性跳过追加并注释)。 + +### 7.3 场景演算 + +| 场景 | 磁盘 | 内存 | merge 结果 | +|------|------|------|-----------| +| 正常追加 | m1-5 | m1-5+m6 | m1-6(同现状) | +| 旧实例回写 | m1-10 | m1-5 | m1-10(等于没写) ✓ | +| 分叉 | m1-10 | m1-5',x1,x2 | m1-5',m6-10,x1,x2 全保留 ✓ | +| 浅压缩打标+旧实例 | m1-10 | m1'-5'(打标) | m1'-5',m6-10:打标生效且不丢新消息 ✓ | + +### 7.4 已知折衷 + +- 同 id 消息取内存版:若旧实例持有同 id 旧内容(如打标前版本)且触发慢路径,会盖回旧内容——消息不被修改是常态,概率极低,危害从"丢消息"降级为"单条字段旧"。 +- metadata/todo 保持现状(内存覆写),不在本次范围。 +- C-full(内存纯缓存+单写点)留待 merge 观察期后评估(§7.1 已证明边际收益有限)。 + +### 7.5 测试与回滚 + +- `verify_save_guard.py` 扩展:缩减矫正/分叉保留/快路径等价/打标生效/豁免覆写 5 类场景。 +- 回滚:单 commit revert 即回到 A 守卫语义。 diff --git a/utils/conversation_manager/crud_mixin.py b/utils/conversation_manager/crud_mixin.py index 5ed2381d..c4b70297 100644 --- a/utils/conversation_manager/crud_mixin.py +++ b/utils/conversation_manager/crud_mixin.py @@ -308,6 +308,53 @@ class CrudMixin: except Exception as e: print(f"⌘ 更新对话索引失败: {e}") + @staticmethod + def _merge_messages_by_id(disk_messages: List[Dict], new_messages: List[Dict]): + """按 message_id 合并磁盘与内存消息(方向 C:merge-on-save)。 + + 规则: + - 快路径:内存 id 序列前缀包含磁盘 id 序列(正常追加)→ 直接返回内存版; + - 慢路径:以磁盘为基,同 id 取内存版(浅压缩打标等修改生效), + 内存独有 id 消息按原序追加在后;无 message_id 的内存消息防御性跳过追加 + (存量消息 100% 有 id,新消息由 add_conversation 生成必有 id)。 + + Returns: + (merged, appended, healed): + merged 合并后的消息列表 + appended 内存独有而被追加的消息数 + healed 磁盘有而内存缺失(被矫正保留)的消息数 + """ + disk_ids = [m.get("message_id") for m in disk_messages] + new_ids = [m.get("message_id") for m in new_messages] + # 快路径:前缀追加,与现状全量覆写语义一致 + if len(new_ids) >= len(disk_ids) and new_ids[:len(disk_ids)] == disk_ids: + return list(new_messages), 0, 0 + + new_by_id = {m.get("message_id"): m for m in new_messages if m.get("message_id")} + merged: List[Dict] = [] + seen = set() + healed = 0 + for dm in disk_messages: + mid = dm.get("message_id") + if mid and mid in new_by_id: + merged.append(new_by_id[mid]) + else: + merged.append(dm) + if mid: + healed += 1 + if mid: + seen.add(mid) + appended = 0 + for m in new_messages: + mid = m.get("message_id") + if not mid: + continue + if mid not in seen: + merged.append(m) + seen.add(mid) + appended += 1 + return merged, appended, healed + def save_conversation( self, conversation_id: str, @@ -344,19 +391,30 @@ class CrudMixin: print(f"⚠️ 对话 {conversation_id} 不存在,无法更新") return False - # 防回退守卫:消息数缩减且未显式豁免时拒绝写入。 - # 工作区级/对话级 terminal 双实例并存时,旧实例内存历史可能落后于磁盘, - # 「切换/新建对话前保存当前对话」「__del__」等路径会把旧历史回写覆盖新消息。 + # 方向 C(merge-on-save):正常保存不做全量覆写,按 message_id 合并, + # 任何实例任何时机的写回都不会丢消息(旧内存回写从根免疫)。 + # 仅检查点恢复等 allow_shrink=True 路径保持覆写语义。 old_messages = existing_data.get("messages") old_len = len(old_messages) if isinstance(old_messages, list) else 0 new_len = len(messages) if isinstance(messages, list) else 0 + if not allow_shrink and isinstance(old_messages, list) and old_messages: + merged, appended, healed = self._merge_messages_by_id(old_messages, messages or []) + if healed or appended: + print( + f"🔀 [ConvSaveMerge] {conversation_id}: " + f"磁盘 {old_len} 条 / 内存 {new_len} 条 → 合并 {len(merged)} 条" + f"(矫正保留磁盘 {healed} 条,追加内存独有 {appended} 条)" + ) + messages = merged + new_len = len(messages) + # 守卫断言:merge 后恒不缩减(触发即 merge 实现 bug);豁免路径跳过。 if new_len < old_len and not allow_shrink: print( f"🚨 [ConvSaveGuard] 拒绝保存 {conversation_id}: " - f"消息数 {old_len} -> {new_len} 缩减且未豁免(疑似旧内存回写)" + f"消息数 {old_len} -> {new_len} 缩减且未豁免(merge 断言失败,疑似 bug)" ) return False - + # 更新数据 existing_data["messages"] = messages existing_data["updated_at"] = datetime.now().isoformat()