agent-Specialization/docs/conversation_load_unification_plan.md

166 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 对话加载统一化计划防回写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/<id>/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. loadingsidebar 来源先 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` 通过
- newold 通过`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 误判导致内容重复或缺失 | 判据移植自前端现逻辑语义不变误判方向与现状一致现状同样可能误判 |
| 回滚 | AB 各为独立 commit可分别 revert前端保留 `restoreTaskState` 原路径代码注释标记便于快速还原 |
## 7. 第三步(方向 C展望
A+B 稳定后评估内存 `conversation_history` 降级为文件缓存所有写入路径直写文件 WALchat task 主循环与 auto_save 合并为单写点从根上消灭"多实例内存副本"问题届时本计划的 A1 守卫可降级为断言A3 调试代码移除