From a9fe6ae6229ba84d0d554a2146867c9eb610a001 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Mon, 20 Jul 2026 21:23:29 +0800 Subject: [PATCH] =?UTF-8?q?fix(server):=20=E5=AF=B9=E8=AF=9D=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E9=98=B2=E5=9B=9E=E9=80=80=E5=AE=88=E5=8D=AB=E4=B8=8E?= =?UTF-8?q?load=E5=90=8C=E5=AF=B9=E8=AF=9D=E8=B7=B3=E8=BF=87=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E5=89=8D=E4=BF=9D=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit crud_mixin.save_conversation 新增 allow_shrink 参数与守卫:消息数缩减且未 豁免时拒绝写入,堵住工作区级/对话级双实例旧内存回写(当日三次实锤: load切换前保存542→506、新建前保存104→77、__del__析构103→77)。 检查点恢复作为唯一合法缩减路径显式豁免。load_conversation_by_id 加载 目标==当前对话时跳过切换前保存(接下来以磁盘为准重载)。debug trace 增加 shrink_suspect 字段覆盖旧非空回写场景。 --- server/conversation.py | 2 ++ utils/context_manager/conversation_mixin.py | 12 +++++++++-- utils/conversation_manager/crud_mixin.py | 24 ++++++++++++++++++--- 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/server/conversation.py b/server/conversation.py index 226f1823..018f2d14 100644 --- a/server/conversation.py +++ b/server/conversation.py @@ -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("保存恢复后的对话失败") diff --git a/utils/context_manager/conversation_mixin.py b/utils/context_manager/conversation_mixin.py index 59e137c8..9eb0249a 100644 --- a/utils/context_manager/conversation_mixin.py +++ b/utils/context_manager/conversation_mixin.py @@ -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 diff --git a/utils/conversation_manager/crud_mixin.py b/utils/conversation_manager/crud_mixin.py index 37ad39d1..5ed2381d 100644 --- a/utils/conversation_manager/crud_mixin.py +++ b/utils/conversation_manager/crud_mixin.py @@ -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()