diff --git a/.astrion/memory/conversation_model_persistence.md b/.astrion/memory/conversation_model_persistence.md index 64655da0..71395e36 100644 --- a/.astrion/memory/conversation_model_persistence.md +++ b/.astrion/memory/conversation_model_persistence.md @@ -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//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//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` 返回对话保存模型。 diff --git a/docs/conversation_level_terminal_design.md b/docs/conversation_level_terminal_design.md index 5f9baf89..beb85353 100644 --- a/docs/conversation_level_terminal_design.md +++ b/docs/conversation_level_terminal_design.md @@ -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//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 真实回收实测 diff --git a/server/context.py b/server/context.py index 9a54cd3d..4ffdb75f 100644 --- a/server/context.py +++ b/server/context.py @@ -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 diff --git a/server/conversation.py b/server/conversation.py index c734aa8e..226f1823 100644 --- a/server/conversation.py +++ b/server/conversation.py @@ -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, diff --git a/server/tasks/api.py b/server/tasks/api.py index 3780a1be..e1440fc1 100644 --- a/server/tasks/api.py +++ b/server/tasks/api.py @@ -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,