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:
parent
ccdc101192
commit
a9fe6ae622
@ -1379,6 +1379,8 @@ def _restore_checkpoint_to_conversation(
|
|||||||
model_key=restore_model_key,
|
model_key=restore_model_key,
|
||||||
has_images=restore_has_images,
|
has_images=restore_has_images,
|
||||||
has_videos=restore_has_videos,
|
has_videos=restore_has_videos,
|
||||||
|
# 恢复旧检查点是唯一合法的消息数缩减路径,显式豁免防回退守卫
|
||||||
|
allow_shrink=True,
|
||||||
)
|
)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise VersioningError("保存恢复后的对话失败")
|
raise VersioningError("保存恢复后的对话失败")
|
||||||
|
|||||||
@ -189,8 +189,16 @@ class ConversationMixin:
|
|||||||
Returns:
|
Returns:
|
||||||
bool: 加载是否成功
|
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()
|
self.save_current_conversation()
|
||||||
|
|
||||||
# 加载指定对话:先按ID路由到对应 manager
|
# 加载指定对话:先按ID路由到对应 manager
|
||||||
|
|||||||
@ -50,6 +50,7 @@ def _debug_conversation_save_trace(conversation_id: str, file_path, data: Dict):
|
|||||||
except Exception:
|
except Exception:
|
||||||
old_len = None
|
old_len = None
|
||||||
wipe_suspect = bool(old_len and new_len == 0)
|
wipe_suspect = bool(old_len and new_len == 0)
|
||||||
|
shrink_suspect = bool(old_len and 0 < new_len < old_len)
|
||||||
# 只保留项目内调用帧,排除标准库/第三方
|
# 只保留项目内调用帧,排除标准库/第三方
|
||||||
try:
|
try:
|
||||||
repo_root = Path(__file__).resolve().parents[2]
|
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,
|
"old_msg_len": old_len,
|
||||||
"new_msg_len": new_len,
|
"new_msg_len": new_len,
|
||||||
"wipe_suspect": wipe_suspect,
|
"wipe_suspect": wipe_suspect,
|
||||||
|
"shrink_suspect": shrink_suspect,
|
||||||
"title": (data.get("title") if isinstance(data, dict) else None),
|
"title": (data.get("title") if isinstance(data, dict) else None),
|
||||||
"thread": threading.current_thread().name,
|
"thread": threading.current_thread().name,
|
||||||
"stack_tail": frames[-14:],
|
"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)
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
with open(log_dir / "conversation_save_debug.log", "a", encoding="utf-8") as f:
|
with open(log_dir / "conversation_save_debug.log", "a", encoding="utf-8") as f:
|
||||||
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||||
if wipe_suspect:
|
if wipe_suspect or shrink_suspect:
|
||||||
print(f"🚨 [ConvSaveDebug] 对话 {conversation_id} 消息将被空列表覆盖!栈: {frames[-14:]}")
|
kind = "空列表覆盖" if wipe_suspect else f"缩减 {old_len}->{new_len}"
|
||||||
|
print(f"🚨 [ConvSaveDebug] 对话 {conversation_id} 消息将被{kind}!栈: {frames[-14:]}")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@ -318,7 +321,8 @@ class CrudMixin:
|
|||||||
has_videos: Optional[bool] = None,
|
has_videos: Optional[bool] = None,
|
||||||
project_file_tree: Optional[str] = None,
|
project_file_tree: Optional[str] = None,
|
||||||
project_statistics: Optional[Dict] = None,
|
project_statistics: Optional[Dict] = None,
|
||||||
project_snapshot_at: Optional[str] = None
|
project_snapshot_at: Optional[str] = None,
|
||||||
|
allow_shrink: bool = False
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
保存对话(更新现有对话)
|
保存对话(更新现有对话)
|
||||||
@ -328,6 +332,7 @@ class CrudMixin:
|
|||||||
messages: 消息列表
|
messages: 消息列表
|
||||||
project_path: 项目路径
|
project_path: 项目路径
|
||||||
thinking_mode: 思考模式
|
thinking_mode: 思考模式
|
||||||
|
allow_shrink: 允许消息数缩减(仅检查点恢复等合法缩减路径使用)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: 保存是否成功
|
bool: 保存是否成功
|
||||||
@ -339,6 +344,19 @@ class CrudMixin:
|
|||||||
print(f"⚠️ 对话 {conversation_id} 不存在,无法更新")
|
print(f"⚠️ 对话 {conversation_id} 不存在,无法更新")
|
||||||
return False
|
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["messages"] = messages
|
||||||
existing_data["updated_at"] = datetime.now().isoformat()
|
existing_data["updated_at"] = datetime.now().isoformat()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user