# 对话加载统一化计划:防回写(A)+ 统一加载协议(B) > 2026-07-20 制定。状态:实施中。 > 前置事实报告:`sub_agent_results/frontend_load_flow/frontend_load_flow.md`(前端链路代码事实)。 ## 1. 背景与动机 ### 1.1 P0:旧内存回写(数据丢失,当日三次实锤) 8092 实例 `conversation_save_debug.log` 证据: | 时间 | 对话 | 缩减 | 调用栈末端 | |------|------|------|-----------| | 19:51:10 | conv_20260720_193008_709 | 542→506 | `load_conversation_by_id` → `save_current_conversation` | | 19:29:27 | conv_20260720_155350_423 | 506→101 | 同上 | | 15:53:50 | conv_20260720_151851_314 | 104→77 | `start_new_conversation` → `save_current_conversation` | | 15:53:22 | conv_20260720_151851_314 | 103→77 | `WebTerminal.__del__` → `save_current_conversation` | 根因:工作区级 terminal(`@with_terminal` 注入)与对话级 terminal(`get_user_resources(conversation_id)`)是两个对象、两份 `conversation_history` 内存。任务在对话级实例上推进历史,工作区级实例停留在旧状态;其"切换/新建对话前保存当前对话"和析构保存把旧历史写回磁盘,覆盖新消息。 已有防御(`8628954f` 非空守卫、debug trace 的 `wipe_suspect`)只检测"空列表覆盖",不检测"旧非空覆盖新非空"。 ### 1.2 P1:运行中刷新的两段式体验 - 第一段:`GET /{id}/messages` 返回**文件态**历史(文件只在每条消息完成时 auto_save,运行中的流式 chunk 不在其中),表现为"加载到最近一条完整消息处"。 - 第二段:`restoreTaskState()` 与历史加载并行启动但互相等待:等 `messages` 非空(500ms×8 死等,最长 4s)→ 串行 `GET /api/tasks` + `GET /api/tasks/{id}` → `lastEventIndex=0` 全量事件重放。断裂感 = 重试粒度 + 串行请求 + 全量重放。 ### 1.3 P3:入口与职责混乱 - "进入对话"需 4 个接口(PUT load、GET messages、GET tasks、GET running-status)+ 事件轮询,无单一入口。 - 防重复加载靠三道手工保险丝(`skipConversationHistoryReload`、`lastHistoryLoadedConversationId`、事件 `task_id:idx` Set)。 - safe_nav / 全量 load 双轨制靠"工作区活跃任务"的**内存态**判定,任务刚结束或进程重启后走错分支(19:51 回写即因此进入全量 load)。 ## 2. 方案 A:防回写(止血) ### A1. `save_conversation` 防回退守卫(`utils/conversation_manager/crud_mixin.py`) - 新增参数 `allow_shrink: bool = False`。 - 在 `existing_data = self.load_conversation(...)` 之后、覆写 `messages` 之前: - `new_len < old_len` 且 `not allow_shrink` → 打印 🚨 警告并 `return False`,不写盘。 - 合法缩减路径显式豁免:**检查点恢复**(`server/conversation.py` `_restore_checkpoint_to_conversation`)传 `allow_shrink=True`。 > ⚠️ **语义已演进(方向 C 后)**:缩减写回不再拒绝,而是按 `message_id` 合并矫正(见 §7);本小节描述为 A 落地时的历史设计,当前行为以代码为准。 - 已确认不需要豁免的路径:压缩(in-place 打 `deep_compacted` 标记,消息数不减)、设置保存(原样传递内存历史)、新建对话后同步模型(old_len=0)。 ### A2. `load_conversation_by_id` 同对话跳过切换前保存(`utils/context_manager/conversation_mixin.py`) - 现行为:加载任何对话前无条件 `save_current_conversation()`。 - 改为:加载目标 == 当前对话(`conv_` 前缀归一化后比较)时跳过保存——接下来会以磁盘为准重载,保存不仅无意义,还是回写源头。 - 目标 != 当前对话时保留保存(内存领先时有效),落后时由 A1 守卫拦截。 ### A3. debug trace 增强(同文件,临时调试代码) - `_debug_conversation_save_trace` 增加 `shrink_suspect`(`new_len < old_len`)字段,弥补只检测 `new==0` 的盲区。 - 该调试代码在确认问题根治后应整体移除(已记入项目记忆)。 ## 3. 方案 B:统一加载协议 ### B1. 后端聚合接口 `GET /api/conversations//bootstrap` 新蓝图 `server/conversation_bootstrap.py`(注册于 `app_legacy.py`,不动 `server/conversation.py` 现有路由)。**只读语义**:不写对话文件、不调用 `terminal.load_conversation`、不切换工作区级 terminal 当前上下文,天然规避 safe_nav 双轨问题;但为聚合后台运行态会按需获取/创建对话级 terminal(与既有 running-status 端点行为一致,属内存态操作)。 响应契约: ```jsonc { "success": true, "data": { "conversation_id": "conv_xxx", "meta": { "title": "...", "run_mode": "thinking", "thinking_mode": true, "model_key": "...", "multi_agent_mode": false, "permission_mode": "...", "execution_mode": "...", "network_permission": "...", "messages_count": 542 }, "messages": [ /* 文件态消息全量,同 GET /{id}/messages */ ], "running": { // 复用 running-status 聚合逻辑(主 task + 传统子智能体 + 后台命令 + 多智能体) "is_main_running": true, "main_task_id": "...", "main_task_type": "chat", "has_running_sub_agents": false, "has_running_background_commands": false, "has_running_multi_agent": false, "is_truly_active": true }, "task_replay": { // 仅 is_main_running 时存在 "task_id": "...", "event_count": 137, "needs_rebuild": true, // 后端判定(见下) "replay_from": 0 // needs_rebuild ? 0 : event_count(从新事件续播) } } } ``` `needs_rebuild` 后端判定(移植自前端 `compression.ts:218-263` 判据,任一成立即 true): 1. 文件态末尾消息不是 assistant; 2. 文件态末尾是空 assistant(无 content 且无 actions); 3. 任务事件流显示处于流式中段(末尾区间存在未配对的 `thinking_chunk`/`text_chunk`,或 `ai_message_start` 之后无对应完成事件); 4. 文件态末尾 assistant 存在 status=working / streaming 的进行中 action。 实现要点: - 任务事件来源:`task_manager.list_tasks(username)` 找该对话活跃 task(复用 running-status 的查找逻辑),直接遍历 `rec.events`(deque,元素含 `idx`)。 - `get_user_resources(username, workspace_id, conversation_id)` 惰性创建对话级 terminal 用于读取 sub_agent_manager / background_command_manager 状态——A 修复后此路径安全。 - `@with_terminal` 注入的工作区级 terminal 仅用于取 `context_manager` 读文件,**绝不调用** `load_conversation`。 ### B2. 前端统一编排 `enterConversation(convId)` 新增 `static/src/app/methods/conversation/bootstrap.ts`,单一入口,取代"PUT load + GET messages + GET tasks + 死等重试"串行链: ``` enterConversation(convId, { source: 'refresh' | 'sidebar' }) state: idle → loading → ready → replaying → live 1. loading:sidebar 来源先 resetAllStates()(保留现有视觉语义) 2. GET bootstrap(一次请求) 3. 渲染 meta(标题/模式/模型)+ renderHistoryMessages(messages)(复用现有渲染,改为接受数据而非自行 fetch) 4. running.is_truly_active: - 主任务 → clearProcessedEvents() + lastEventIndex = task_replay.replay_from + startPolling()(needs_rebuild 时先清空末尾 assistant 的 actions,逻辑保留) + toast「任务恢复」(保留) - 仅后台活跃 → 交由现有对账 probe 兜底(不变) 5. ready/live ``` 调用点改造: - `bootstrapRoute()`(route.ts):解析 URL 后改调 `enterConversation`,**移除 PUT load 调用**。 - `loadConversation()`(conversation/load.ts):改调 `enterConversation`,移除 PUT load 与显式 `fetchAndDisplayHistory`。 - `fetchAndDisplayHistory`(history.ts):拆分为 `renderHistoryMessages(messages)`(纯渲染,保留)与数据获取(由 bootstrap 承担);保留 `lastHistoryLoadedConversationId` 防重。 - `restoreTaskState()`(compression.ts):删除"等 messages 非空"死等段与 `GET /api/tasks` 查找段(由 bootstrap 的 task_replay 取代);保留事件重放执行与恢复 toast。对账 `probe.ts` 不变(兜底)。 - `loadInitialData`(socket.ts):移除手动 `fetchAndDisplayHistory()` 调用(由 enterConversation 覆盖);`GET /api/conversations/current` 改为读取 bootstrap meta 的本地状态。 ### B3. PUT load 的去留 - 后端路由**保留**(API 兼容),前端进入对话不再调用。 - 工作区级 terminal 的"当前对话"不再被前端导航改变——这反而消除了 A 类的另一个回写触发面;其 `_ensure_conversation`(最近对话兜底)行为不变。 - `socketio.emit` 的 `conversation_changed`/`conversation_loaded` 仍由 PUT load 发出;前端为纯 REST 轮询,不依赖这两个事件(已验证 `initSocket` 空实现)。 ## 4. 非目标(本轮不做) - **P2**(reasoning_content 历史路径渲染两遍):用户确认从未出现,不修。 - **方向 C**(内存历史仅作缓存、写入即落盘的单一事实来源):动 chat task 主循环,A+B 验证稳定后再评估(本文档 §7)。 - 事件重放幂等性改造(保持现有从 replay_from 重放的语义)。 - Android/桌面端协议变更(同一前端 build,随 Web 生效)。 ## 5. 测试计划 1. **静态**:`python3 -m py_compile` 改动文件;`npm run build`(含 stylelint/tsc)。 2. **冒烟**:`python -m unittest test.test_server_refactor_smoke`。 3. **守卫单元脚本**(`_experiments/verify_save_guard.py`,`ASTRION_DATA_ROOT=/tmp/...` 隔离): - old=5/new=3 无豁免 → 拒绝且文件不变;`allow_shrink=True` → 通过; - new≥old → 通过;`load_conversation_by_id` 同对话不触发 `save_current_conversation`(mock 计数),异对话触发。 4. **隔离实例集成测试**:独立端口(如 8123)+ `ASTRION_DATA_ROOT=/tmp/astrion_boot_test` 启动实例(不碰 8091/8092 及其数据): - 建对话→发消息→`GET bootstrap` 断言 meta/messages/running 结构; - 任务运行中调 `bootstrap` 断言 `task_replay.needs_rebuild/replay_from`; - 模拟回写场景(直接向 manager 塞旧历史后 save)断言守卫拒绝。 5. **人工回归清单**(用户):刷新对话 / 侧边栏切换 / 运行中刷新(应无两段式断裂)/ 压缩后刷新 / 多智能体对话刷新。 ## 6. 风险与回滚 | 风险 | 缓解 | |------|------| | 守卫误拦合法缩减 | 全量调用点已盘点(仅检查点恢复豁免);拦截仅拒绝写入并告警,不损坏现有文件 | | 前端去 PUT load 后某隐式依赖工作区级 current 的功能异常 | PUT load 后端保留;`loadInitialData` 其余调用不变;人工回归清单覆盖 | | needs_rebuild 误判导致内容重复或缺失 | 判据移植自前端现逻辑,语义不变;误判方向与现状一致(现状同样可能误判) | | 回滚 | A、B 各为独立 commit,可分别 revert;前端保留 `restoreTaskState` 原路径代码注释标记,便于快速还原 | ## 7. 第三步(方向 C):合并写(C-merge) > 2026-07-20 调研定稿,用户拍板"先 merge 后评估 full"。A+B 已上线并人工回归通过。 ### 7.1 调研结论(决定方案形态) - **"写入即落盘"现状已达成**:`add_conversation`(唯一消息追加入口)append 后立即同步全量落盘,无防抖。回退根源不是落盘时机,而是"多实例都能全量覆写"。 - 消息追加仅 1 入口(`message_mixin.add_conversation`);整体替换仅 4 处(加载/新建/浅压缩 in-place/清空)。 - 每条消息有稳定 `message_id`(`msg_`,存量抽查 100% 覆盖)→ 合并键可靠。 - 合法"覆写语义"仅两处:检查点恢复(已豁免);浅压缩是 in-place 打标不缩减、手动压缩是新建对话——均不需要缩减豁免。 ### 7.2 方案:merge-on-save(合并写代替覆写) `crud_mixin.save_conversation` 中 `existing_data["messages"] = messages` 全量覆写改为按 `message_id` 合并: 1. **快路径**(正常追加,99%):内存 id 序列前缀包含磁盘 id 序列 → 直接用内存版(与现状一致)。 2. **慢路径**(旧实例写回/分叉):以磁盘为基,同 id 消息取内存版(浅压缩打标等修改生效),内存独有 id 消息按原序追加在后 → 任何实例任何时机写回都不丢消息,回退从根免疫。 3. **豁免路径**(`allow_shrink=True`,仅检查点恢复):保持覆写语义。 4. A1 守卫演化为对 merged 的断言(恒不缩减,触发即 bug);**缩减写回不再拒绝而是矫正**(磁盘不动 + 旧实例独有消息追加救回),优于 A 的纯拒绝。 5. 慢路径实质矫正发生时打印 `🔀 [ConvSaveMerge]` 醒目日志(观察期信号;无 id 消息防御性跳过追加并注释)。 ### 7.3 场景演算 | 场景 | 磁盘 | 内存 | merge 结果 | |------|------|------|-----------| | 正常追加 | m1-5 | m1-5+m6 | m1-6(同现状) | | 旧实例回写 | m1-10 | m1-5 | m1-10(等于没写) ✓ | | 分叉 | m1-10 | m1-5',x1,x2 | m1-5',m6-10,x1,x2 全保留 ✓ | | 浅压缩打标+旧实例 | m1-10 | m1'-5'(打标) | m1'-5',m6-10:打标生效且不丢新消息 ✓ | ### 7.4 已知折衷 - 同 id 消息取内存版:若旧实例持有同 id 旧内容(如打标前版本)且触发慢路径,会盖回旧内容——消息不被修改是常态,概率极低,危害从"丢消息"降级为"单条字段旧"。 - metadata/todo 保持现状(内存覆写),不在本次范围。 - C-full(内存纯缓存+单写点)留待 merge 观察期后评估(§7.1 已证明边际收益有限)。 ### 7.5 测试与回滚 - `verify_save_guard.py` 扩展:缩减矫正/分叉保留/快路径等价/打标生效/豁免覆写 5 类场景。 - 回滚:单 commit revert 即回到 A 守卫语义。