fix(server): 对话保存防回退守卫与load同对话跳过切换前保存

crud_mixin.save_conversation 新增 allow_shrink 参数与守卫:消息数缩减且未
豁免时拒绝写入,堵住工作区级/对话级双实例旧内存回写(当日三次实锤:
load切换前保存542→506、新建前保存104→77、__del__析构103→77)。
检查点恢复作为唯一合法缩减路径显式豁免。load_conversation_by_id 加载
目标==当前对话时跳过切换前保存(接下来以磁盘为准重载)。debug trace
增加 shrink_suspect 字段覆盖旧非空回写场景。
This commit is contained in:
JOJO 2026-07-20 21:23:29 +08:00
parent ccdc101192
commit a9fe6ae622
3 changed files with 33 additions and 5 deletions

View File

@ -1379,6 +1379,8 @@ def _restore_checkpoint_to_conversation(
model_key=restore_model_key,
has_images=restore_has_images,
has_videos=restore_has_videos,
# 恢复旧检查点是唯一合法的消息数缩减路径,显式豁免防回退守卫
allow_shrink=True,
)
if not ok:
raise VersioningError("保存恢复后的对话失败")

View File

@ -189,8 +189,16 @@ class ConversationMixin:
Returns:
bool: 加载是否成功
"""
# 先保存当前对话
if self.current_conversation_id and self.conversation_history:
# 先保存当前对话(加载目标就是当前对话时跳过:接下来会以磁盘为准重载,
# 若本实例内存落后于磁盘,保存反而把旧历史回写覆盖新消息——双实例并存时
# 这是已实锤的回退源头;其余路径的落后写入由 save_conversation 守卫拦截)
current_norm = (self.current_conversation_id or "").removeprefix("conv_")
target_norm = (conversation_id or "").removeprefix("conv_")
if (
self.current_conversation_id
and self.conversation_history
and current_norm != target_norm
):
self.save_current_conversation()
# 加载指定对话先按ID路由到对应 manager

View File

@ -50,6 +50,7 @@ def _debug_conversation_save_trace(conversation_id: str, file_path, data: Dict):
except Exception:
old_len = None
wipe_suspect = bool(old_len and new_len == 0)
shrink_suspect = bool(old_len and 0 < new_len < old_len)
# 只保留项目内调用帧,排除标准库/第三方
try:
repo_root = Path(__file__).resolve().parents[2]
@ -71,6 +72,7 @@ def _debug_conversation_save_trace(conversation_id: str, file_path, data: Dict):
"old_msg_len": old_len,
"new_msg_len": new_len,
"wipe_suspect": wipe_suspect,
"shrink_suspect": shrink_suspect,
"title": (data.get("title") if isinstance(data, dict) else None),
"thread": threading.current_thread().name,
"stack_tail": frames[-14:],
@ -82,8 +84,9 @@ def _debug_conversation_save_trace(conversation_id: str, file_path, data: Dict):
log_dir.mkdir(parents=True, exist_ok=True)
with open(log_dir / "conversation_save_debug.log", "a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
if wipe_suspect:
print(f"🚨 [ConvSaveDebug] 对话 {conversation_id} 消息将被空列表覆盖!栈: {frames[-14:]}")
if wipe_suspect or shrink_suspect:
kind = "空列表覆盖" if wipe_suspect else f"缩减 {old_len}->{new_len}"
print(f"🚨 [ConvSaveDebug] 对话 {conversation_id} 消息将被{kind}!栈: {frames[-14:]}")
except Exception:
pass
@ -318,7 +321,8 @@ class CrudMixin:
has_videos: Optional[bool] = None,
project_file_tree: Optional[str] = None,
project_statistics: Optional[Dict] = None,
project_snapshot_at: Optional[str] = None
project_snapshot_at: Optional[str] = None,
allow_shrink: bool = False
) -> bool:
"""
保存对话更新现有对话
@ -328,6 +332,7 @@ class CrudMixin:
messages: 消息列表
project_path: 项目路径
thinking_mode: 思考模式
allow_shrink: 允许消息数缩减仅检查点恢复等合法缩减路径使用
Returns:
bool: 保存是否成功
@ -339,6 +344,19 @@ class CrudMixin:
print(f"⚠️ 对话 {conversation_id} 不存在,无法更新")
return False
# 防回退守卫:消息数缩减且未显式豁免时拒绝写入。
# 工作区级/对话级 terminal 双实例并存时,旧实例内存历史可能落后于磁盘,
# 「切换/新建对话前保存当前对话」「__del__」等路径会把旧历史回写覆盖新消息。
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 new_len < old_len and not allow_shrink:
print(
f"🚨 [ConvSaveGuard] 拒绝保存 {conversation_id}: "
f"消息数 {old_len} -> {new_len} 缩减且未豁免(疑似旧内存回写)"
)
return False
# 更新数据
existing_data["messages"] = messages
existing_data["updated_at"] = datetime.now().isoformat()