docs: 对话加载统一化计划(A防回写+B统一加载协议),屏蔽子智能体交付目录

This commit is contained in:
JOJO 2026-07-20 21:24:00 +08:00
parent a9fe6ae622
commit 17c48474d9
2 changed files with 168 additions and 0 deletions

3
.gitignore vendored
View File

@ -55,3 +55,6 @@ easyagent/models.json
# Playwright MCP 调试快照(工具自动生成,不进仓库)
.playwright-mcp/
# 子智能体交付物(过程材料,不进仓库)
sub_agent_results/

View File

@ -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/<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` → 通过;
- 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` 降级为文件缓存,所有写入路径直写文件(或 WALchat task 主循环与 auto_save 合并为单写点,从根上消灭"多实例内存副本"问题。届时本计划的 A1 守卫可降级为断言A3 调试代码移除。