14 KiB
对话加载统一化计划:防回写(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:idxSet)。 - 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/<id>/bootstrap
新蓝图 server/conversation_bootstrap.py(注册于 app_legacy.py,不动 server/conversation.py 现有路由)。只读语义:不写对话文件、不调用 terminal.load_conversation、不切换工作区级 terminal 当前上下文,天然规避 safe_nav 双轨问题;但为聚合后台运行态会按需获取/创建对话级 terminal(与既有 running-status 端点行为一致,属内存态操作)。
响应契约:
{
"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):
- 文件态末尾消息不是 assistant;
- 文件态末尾是空 assistant(无 content 且无 actions);
- 任务事件流显示处于流式中段(末尾区间存在未配对的
thinking_chunk/text_chunk,或ai_message_start之后无对应完成事件); - 文件态末尾 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. 测试计划
- 静态:
python3 -m py_compile改动文件;npm run build(含 stylelint/tsc)。 - 冒烟:
python -m unittest test.test_server_refactor_smoke。 - 守卫单元脚本(
_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 计数),异对话触发。
- old=5/new=3 无豁免 → 拒绝且文件不变;
- 隔离实例集成测试:独立端口(如 8123)+
ASTRION_DATA_ROOT=/tmp/astrion_boot_test启动实例(不碰 8091/8092 及其数据):- 建对话→发消息→
GET bootstrap断言 meta/messages/running 结构; - 任务运行中调
bootstrap断言task_replay.needs_rebuild/replay_from; - 模拟回写场景(直接向 manager 塞旧历史后 save)断言守卫拒绝。
- 建对话→发消息→
- 人工回归清单(用户):刷新对话 / 侧边栏切换 / 运行中刷新(应无两段式断裂)/ 压缩后刷新 / 多智能体对话刷新。
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_<uuid4>,存量抽查 100% 覆盖)→ 合并键可靠。 - 合法"覆写语义"仅两处:检查点恢复(已豁免);浅压缩是 in-place 打标不缩减、手动压缩是新建对话——均不需要缩减豁免。
7.2 方案:merge-on-save(合并写代替覆写)
crud_mixin.save_conversation 中 existing_data["messages"] = messages 全量覆写改为按 message_id 合并:
- 快路径(正常追加,99%):内存 id 序列前缀包含磁盘 id 序列 → 直接用内存版(与现状一致)。
- 慢路径(旧实例写回/分叉):以磁盘为基,同 id 消息取内存版(浅压缩打标等修改生效),内存独有 id 消息按原序追加在后 → 任何实例任何时机写回都不丢消息,回退从根免疫。
- 豁免路径(
allow_shrink=True,仅检查点恢复):保持覆写语义。 - A1 守卫演化为对 merged 的断言(恒不缩减,触发即 bug);缩减写回不再拒绝而是矫正(磁盘不动 + 旧实例独有消息追加救回),优于 A 的纯拒绝。
- 慢路径实质矫正发生时打印
🔀 [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 守卫语义。