diff --git a/.gitignore b/.gitignore index 0c6b4ce7..619c88ca 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,6 @@ easyagent/models.json # Playwright MCP 调试快照(工具自动生成,不进仓库) .playwright-mcp/ + +# 子智能体交付物(过程材料,不进仓库) +sub_agent_results/ diff --git a/docs/conversation_load_unification_plan.md b/docs/conversation_load_unification_plan.md new file mode 100644 index 00000000..8e28eb96 --- /dev/null +++ b/docs/conversation_load_unification_plan.md @@ -0,0 +1,165 @@ +# 对话加载统一化计划:防回写(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`。 +- 已确认不需要豁免的路径:压缩(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 双轨问题。 + +响应契约: + +```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)展望 + +A+B 稳定后评估:内存 `conversation_history` 降级为文件缓存,所有写入路径直写文件(或 WAL),chat task 主循环与 auto_save 合并为单写点,从根上消灭"多实例内存副本"问题。届时本计划的 A1 守卫可降级为断言,A3 调试代码移除。