feat(server): 对话保存改为按 message_id 合并写(方向C merge-on-save)

save_conversation 全量覆写改为合并写:快路径前缀追加与现状一致;
慢路径以磁盘为基、同 id 取内存版、内存独有追加在后——旧实例任何
时机的写回都不再丢消息,回退从根免疫。豁免路径(allow_shrink,
仅检查点恢复)保持覆写语义。A 守卫降级为 merged 断言,缩减写回
从拒绝进化为矫正(旧实例独有消息也能救回)。慢路径矫正时打印
🔀 [ConvSaveMerge] 观察日志。详见计划文档 §7。
This commit is contained in:
JOJO 2026-07-20 23:16:04 +08:00
parent 858ff45187
commit 01a1658a99
2 changed files with 102 additions and 7 deletions

View File

@ -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` 降级为文件缓存,所有写入路径直写文件(或 WALchat 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_<uuid4>`,存量抽查 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 守卫语义。

View File

@ -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 合并磁盘与内存消息(方向 Cmerge-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__」等路径会把旧历史回写覆盖新消息
# 方向 Cmerge-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()