fix(server): 对话级隔离边界修复——压缩路由走对话级terminal、回收器竞态防护、空cid任务补建对话
This commit is contained in:
parent
1289c31dfe
commit
6b5b173260
@ -1,66 +1,35 @@
|
||||
---
|
||||
name: conversation_model_persistence
|
||||
description: 当加载/恢复对话时发现模型未恢复,或新建对话后旧对话模型被覆盖时,应该索引本记忆
|
||||
description: 当加载/恢复对话时发现模型未恢复、新建对话后旧对话模型被覆盖、或修改 /api/model、_apply_workspace_personalization_preferences 时,应该索引本记忆
|
||||
---
|
||||
|
||||
# 对话模型持久化约定
|
||||
---
|
||||
name: conversation_model_persistence
|
||||
description: 当加载/恢复对话时发现模型未恢复、新建对话后旧对话模型被覆盖、或修改 /api/model、_apply_workspace_personalization_preferences 时,应该索引本记忆
|
||||
---
|
||||
|
||||
# 对话模型持久化约定(对话级隔离架构版)
|
||||
|
||||
## 核心规则
|
||||
|
||||
- 每个对话的 `model_key` 保存在对话文件的 `metadata.model_key` 中。
|
||||
- 切换模型时(`/api/model`)会立即调用 `utils/conversation_manager/ConversationManager.save_conversation(..., model_key=terminal.model_key)` 写入对话文件。
|
||||
- **加载对话时必须恢复该对话保存的模型**,不能沿用终端当前的默认模型或用户个性化默认模型。
|
||||
- **创建新对话时,必须先保存旧对话、再应用默认模型到新对话**,否则旧对话的 `model_key` 会被默认模型覆盖。
|
||||
- 每个对话的 `model_key` 保存在对话文件的 `metadata.model_key`。
|
||||
- **对话级 terminal(`_bound_conversation_id` 非空)的模型由绑定加载权威恢复**(`WebTerminal._ensure_conversation` 用 `restore_model=True`),其它任何逻辑不得覆盖它。
|
||||
- **工作区级 terminal(未绑定)刻意 `restore_model=False`** 自动加载最近对话:避免 /new 页面显示旧对话模型。
|
||||
|
||||
## 关键实现点
|
||||
## 四条关键防线(2026-07-20 修复,commit 1289c31d)
|
||||
|
||||
### 1. 手动加载对话
|
||||
1. **`core/web_terminal.py::_ensure_conversation`**:绑定路径 `load_conversation(bound_id, restore_model=True)`。工作区自动加载路径保持 `restore_model=False`。
|
||||
2. **`server/chat/settings.py::/api/model`**:仅当请求显式携带 `conversation_id` 且与 terminal `current_conversation_id` 一致时才 `save_conversation`;无 cid(/new 页面)不持久化——工作区级 terminal 的 current 可能是陈旧对话,盲存会把模型写错对话或用陈旧 history 覆盖新消息。
|
||||
3. **前端 `static/src/app/methods/ui/model.ts::handleModelSelect`**:POST /api/model 必须带 `conversation_id: this.currentConversationId`。
|
||||
4. **`server/context.py::_apply_workspace_personalization_preferences`**:对 `_bound_conversation_id` 非空的 terminal 跳过 session 模型恢复与 default_model 应用——否则 session 里的全局模型会把对话级 terminal 恢复好的模型再盖掉(重启后回变默认的直接元凶)。
|
||||
|
||||
路径:`/api/conversations/<conversation_id>/load` → `core/web_terminal.py::WebTerminal.load_conversation()`
|
||||
## 会话级语义
|
||||
|
||||
- 该方法会从对话元数据中恢复 `thinking_mode`、`permission_mode`、`execution_mode`、`network_permission` 等状态。
|
||||
- **必须同时恢复 `model_key`**:
|
||||
```python
|
||||
saved_model_key = meta.get("model_key")
|
||||
if saved_model_key:
|
||||
try:
|
||||
self.set_model(saved_model_key)
|
||||
except Exception as exc:
|
||||
logger.warning("加载对话模型 %s 失败: %s", saved_model_key, exc)
|
||||
```
|
||||
- 恢复后,`terminal.model_key` 即对话保存的模型,`get_status()` 和 API 返回的 `model_key` 都会正确。
|
||||
- `session["model_key"]` 是全局"用户最后选择的模型":用于工作区级 terminal 恢复与 /new 继承,**不代表任何具体对话的模型**。
|
||||
- 任务创建时前端随任务传 `model_key`(`send.ts`),`server/tasks/models.py` 在任务起点 `terminal.set_model(rec.model_key)`——前端是任务模型的权威来源。
|
||||
- `set_model()` 可能因图片/视频不兼容或模型被禁用抛异常,加载场景应捕获降级。
|
||||
- 管理员强制禁用模型的切换(context.py 406/549 行附近)对对话级 terminal 仍然生效,属正当覆盖。
|
||||
|
||||
### 2. 程序重启后自动恢复最近对话
|
||||
## 验证脚本
|
||||
|
||||
路径:`core/web_terminal.py::WebTerminal._ensure_conversation()`
|
||||
|
||||
- Web 终端初始化时会自动加载最近的对话。
|
||||
- 原来的实现只调用 `utils/context_manager/ContextManager.load_conversation_by_id()`,不会恢复 `model_key` 等运行时状态。
|
||||
- 应改为调用 `self.load_conversation(conv_id)`,以复用完整的状态恢复逻辑(含模型恢复)。
|
||||
|
||||
### 3. 创建新对话时保护旧对话的模型
|
||||
|
||||
路径:`core/web_terminal.py::WebTerminal.create_new_conversation()`
|
||||
|
||||
- **错误顺序**:先 `set_model(默认模型)` → 再 `start_new_conversation()` → `start_new_conversation()` 内部先 `save_current_conversation()` → 旧对话被按默认模型保存,覆盖了之前的模型。
|
||||
- **正确顺序**:
|
||||
1. 计算默认 `run_mode`、`permission_mode` 并应用(不改 `model_key`)。
|
||||
2. 调用 `start_new_conversation()`,此时 `terminal.model_key` 仍是旧对话的模型,旧对话被正确保存。
|
||||
3. 新对话创建完成后,再 `set_model(默认模型)`,并 `save_conversation` 更新新对话的 `model_key`。
|
||||
|
||||
### 4. 前端
|
||||
|
||||
路径:`static/src/app/methods/conversation/load.ts::loadConversation()`
|
||||
|
||||
- 前端在调用 `/api/conversations/<id>/load` 后,会根据返回的 `result.model_key` 更新当前模型:
|
||||
```typescript
|
||||
if (typeof result.model_key === 'string' && result.model_key) {
|
||||
this.modelSet(result.model_key);
|
||||
}
|
||||
```
|
||||
- 后端修复后,返回的 `model_key` 即为对话保存的模型,前端会自动应用。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- `set_model()` 可能因图片/视频不兼容或模型被禁用而抛异常,加载场景下应捕获并记录警告,不要让加载失败。
|
||||
- 如果对话元数据中没有 `model_key`(旧数据或新建对话),保持当前模型即可。
|
||||
- `_experiments/verify_model_fix.py`:p1 全周期(带cid切换→落盘、不带cid不污染)、p2 重启后保持;另需验证 `GET /api/status?conversation_id=X` 返回对话保存模型。
|
||||
|
||||
@ -158,3 +158,20 @@
|
||||
- 24h 回收器真实时长未实测(单元覆盖三态;env `CONVERSATION_TERMINAL_TTL_SECONDS` 可调短测)
|
||||
- TerminalPanel 广播过滤前端 UI 未单独截图(逻辑随构建通过;后端 API 层隔离已实测)
|
||||
- 既有 bug 仍未修:assistant reasoning_content 前端渲染两遍(与议题 1/4 无关)
|
||||
|
||||
## 8. 边界排查与修复(2026-07-20 第二轮)
|
||||
|
||||
针对隔离后边界情况的专项排查结论与修复:
|
||||
|
||||
1. **压缩迁移孤儿化(排查结论:不存在)**:深层压缩已全面 in-place(`run_deep_compression`,对话 id 不变);`compression_mixin.compress_conversation` 创建新对话的旧路径已无调用方(死代码)。`compression_finished` 的 `rec.conversation_id` 迁移是同 id 覆写,无孤儿风险。
|
||||
2. **手动压缩走错 terminal(已修复)**:`/api/conversations/<id>/compress` 的 id 在路径里,`with_terminal` 只读 query/body → 原先用工作区级 terminal 执行并把对话加载进去,与对话级 terminal 双持同一对话(可串写)。修复:路由内显式 `get_user_resources(username, conversation_id=normalized_id)` 换到对话级 terminal(`server/conversation.py`)。
|
||||
3. **回收器竞态(已修复)**:原实现判定→关闭→pop 存在窗口,期间新请求可能拿到正在关闭的实例建任务。修复:关闭前打 `_reaper_closing` 标记(`get_user_resources` 见标记原地重建新实例)+ 关闭前二次确认 last_activity/运行任务(取消时清除标记)+ pop 前校验实例身份(`server/context.py`)。验证脚本新增 8a/8b 断言。
|
||||
4. **多智能体 idle 不会被误回收(排查结论:安全)**:`get_conversation_running_status` 中 idle 状态的多智能体任务记录不在终态集合内,仍计入 `has_running_multi_agent`;回收判定依赖该方法,故 idle 等待中的多智能体对话不会被回收。
|
||||
5. **空 cid chat 任务(已修复)**:未带 conversation_id 的 chat 任务原先落在工作区级 terminal 跑(`ensure_conversation_loaded` 在其上新建对话),占用服务 terminal 并造成双持。修复:`create_task_api` 在 cid 缺失时先补建对话文件再建任务(`server/tasks/api.py`)。socket chat 路径前端已不使用(无 `emit('chat')`)。
|
||||
6. **模型持久化四缺陷(已修复,commit 1289c31d)**:见项目记忆 `conversation_model_persistence` 四条防线。
|
||||
|
||||
### 第二轮验证
|
||||
- `_experiments/verify_conversation_level_terminal.py`:11 项断言全过(新增 8a closing 重建 / 8b 二次确认)
|
||||
- `_experiments/verify_model_fix.py`:带 cid 切换落盘、不带 cid 不污染、重启后保持、`/api/status?conversation_id=` 对话级模型恢复全过
|
||||
- `_experiments/test_no_cid_guard.py`:空 cid 补建对话全过
|
||||
- `_experiments/test_reaper_live.sh`:短 TTL 真实回收实测
|
||||
|
||||
@ -276,6 +276,10 @@ def get_user_resources(
|
||||
container_handle = state.container_manager.ensure_container("host", str(project_path), container_key=container_key, preferred_mode="host")
|
||||
usage_tracker = None # 宿主机模式不计配额
|
||||
terminal = state.user_terminals.get(term_key)
|
||||
if terminal is not None and getattr(terminal, "_reaper_closing", False):
|
||||
# 回收器正在关闭该实例,视为不存在并原地重建;
|
||||
# 回收器 pop 前会校验实例身份,不会误删这里新建的 terminal。
|
||||
terminal = None
|
||||
target_project_path = str(project_path)
|
||||
if terminal:
|
||||
should_recreate_terminal = False
|
||||
@ -452,6 +456,10 @@ def get_user_resources(
|
||||
container_handle = state.container_manager.ensure_container(username, str(workspace.project_path), container_key=container_key, preferred_mode="docker")
|
||||
usage_tracker = None if is_api_user else get_or_create_usage_tracker(username, workspace)
|
||||
terminal = state.user_terminals.get(term_key)
|
||||
if terminal is not None and getattr(terminal, "_reaper_closing", False):
|
||||
# 回收器正在关闭该实例,视为不存在并原地重建;
|
||||
# 回收器 pop 前会校验实例身份,不会误删这里新建的 terminal。
|
||||
terminal = None
|
||||
if not terminal:
|
||||
run_mode = session.get('run_mode') if has_request_context() else None
|
||||
thinking_mode_flag = session.get('thinking_mode') if has_request_context() else None
|
||||
@ -862,6 +870,27 @@ def reap_idle_conversation_terminals(now: Optional[float] = None) -> int:
|
||||
continue
|
||||
if _conversation_terminal_has_running_work(username, workspace_id, conversation_id, terminal):
|
||||
continue
|
||||
# 竞态防护:判定到关闭之间存在窗口,期间新请求可能拿到该实例并建任务。
|
||||
# 先打关闭标记(get_user_resources 见到标记会原地重建新实例),
|
||||
# 并二次确认活动时间/运行工作未变化,最后 pop 时校验实例身份。
|
||||
try:
|
||||
terminal._reaper_closing = True
|
||||
except Exception:
|
||||
pass
|
||||
aborted = False
|
||||
latest_active = float(getattr(terminal, "last_activity_at", 0) or 0)
|
||||
if latest_active > last_active:
|
||||
debug_log(f"[ConvTerminalReaper] 关闭前检测到新活动,取消回收: {term_key}")
|
||||
aborted = True
|
||||
elif _conversation_terminal_has_running_work(username, workspace_id, conversation_id, terminal):
|
||||
debug_log(f"[ConvTerminalReaper] 关闭前检测到运行任务,取消回收: {term_key}")
|
||||
aborted = True
|
||||
if aborted:
|
||||
try:
|
||||
terminal._reaper_closing = False
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
try:
|
||||
cm = getattr(terminal, "context_manager", None)
|
||||
if cm and getattr(cm, "current_conversation_id", None):
|
||||
@ -880,9 +909,11 @@ def reap_idle_conversation_terminals(now: Optional[float] = None) -> int:
|
||||
mcp.close_all_clients()
|
||||
except Exception as exc:
|
||||
debug_log(f"[ConvTerminalReaper] 关闭 MCP 失败 {conversation_id}: {exc}")
|
||||
state.user_terminals.pop(term_key, None)
|
||||
reaped += 1
|
||||
debug_log(f"[ConvTerminalReaper] 已回收对话级 terminal: {term_key} (idle {int(now - last_active)}s)")
|
||||
# 仅当缓存里仍是本实例时才移除(可能已被请求侧原地重建)
|
||||
if state.user_terminals.get(term_key) is terminal:
|
||||
state.user_terminals.pop(term_key, None)
|
||||
reaped += 1
|
||||
debug_log(f"[ConvTerminalReaper] 已回收对话级 terminal: {term_key} (idle {int(now - last_active)}s)")
|
||||
return reaped
|
||||
|
||||
|
||||
|
||||
@ -1534,6 +1534,16 @@ def compress_conversation(conversation_id, terminal: WebTerminal, workspace: Use
|
||||
if policy.get("ui_blocks", {}).get("block_compress_conversation"):
|
||||
return jsonify({"success": False, "error": "压缩对话已被管理员禁用"}), 403
|
||||
normalized_id = conversation_id if conversation_id.startswith('conv_') else f"conv_{conversation_id}"
|
||||
# 对话级隔离:压缩必须在该对话的专属 terminal 上执行。
|
||||
# with_terminal 只从 query/body 取 conversation_id,本路由的 id 在路径里,
|
||||
# 默认拿到的是工作区级 terminal——直接用它会把该对话加载进工作区级
|
||||
# terminal,与对话级 terminal 形成双持同一对话(可能互相串写历史)。
|
||||
try:
|
||||
conv_terminal, _ = get_user_resources(username, conversation_id=normalized_id)
|
||||
if conv_terminal is not None:
|
||||
terminal = conv_terminal
|
||||
except RuntimeError as exc:
|
||||
return jsonify({"success": False, "error": str(exc), "code": "resource_busy"}), 503
|
||||
result = asyncio.run(
|
||||
run_deep_compression(
|
||||
web_terminal=terminal,
|
||||
|
||||
@ -170,6 +170,25 @@ def create_task_api():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 对话级隔离兜底:chat 任务必须落在对话级 terminal 上。
|
||||
# 前端正常先 POST /api/conversations 拿 cid 再建任务;直接调 API 未带
|
||||
# conversation_id 时这里补建对话文件,任务随后在对话级 terminal 加载运行,
|
||||
# 避免占用工作区级服务 terminal 并与其形成双持同一对话。
|
||||
if not conversation_id:
|
||||
try:
|
||||
_term_nc, _ws_nc = get_user_resources(username, workspace_id)
|
||||
_cm_nc = getattr(getattr(_term_nc, "context_manager", None), "conversation_manager", None)
|
||||
if _cm_nc is not None:
|
||||
conversation_id = _cm_nc.create_conversation(
|
||||
project_path=str(getattr(_ws_nc, "project_path", "") or "."),
|
||||
run_mode=(run_mode if run_mode in {"fast", "thinking", "deep"} else "fast"),
|
||||
thinking_mode=bool(thinking_mode) if thinking_mode is not None else (run_mode != "fast"),
|
||||
model_key=model_key,
|
||||
)
|
||||
debug_log(f"[TaskAPI] 未携带 conversation_id,已补建对话: {conversation_id}")
|
||||
except Exception as exc:
|
||||
debug_log(f"[TaskAPI] 补建对话失败(继续按无 cid 处理): {exc}")
|
||||
|
||||
try:
|
||||
rec = task_manager.create_chat_task(
|
||||
username,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user