agent-Specialization/docs/conversation_load_unification_plan.md

14 KiB
Raw Blame History

对话加载统一化计划防回写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_idsave_current_conversation
19:29:27 conv_20260720_155350_423 506→101 同上
15:53:50 conv_20260720_151851_314 104→77 start_new_conversationsave_current_conversation
15:53:22 conv_20260720_151851_314 103→77 WebTerminal.__del__save_current_conversation

根因:工作区级 terminal@with_terminal 注入)与对话级 terminalget_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+ 事件轮询,无单一入口。
  • 防重复加载靠三道手工保险丝(skipConversationHistoryReloadlastHistoryLoadedConversationId、事件 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_lennot 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_suspectnew_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

  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.eventsdeque元素含 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
  • fetchAndDisplayHistoryhistory.ts拆分为 renderHistoryMessages(messages)(纯渲染,保留)与数据获取(由 bootstrap 承担);保留 lastHistoryLoadedConversationId 防重。
  • restoreTaskState()compression.ts删除"等 messages 非空"死等段与 GET /api/tasks 查找段(由 bootstrap 的 task_replay 取代);保留事件重放执行与恢复 toast。对账 probe.ts 不变(兜底)。
  • loadInitialDatasocket.ts移除手动 fetchAndDisplayHistory() 调用(由 enterConversation 覆盖);GET /api/conversations/current 改为读取 bootstrap meta 的本地状态。

B3. PUT load 的去留

  • 后端路由保留API 兼容),前端进入对话不再调用。
  • 工作区级 terminal 的"当前对话"不再被前端导航改变——这反而消除了 A 类的另一个回写触发面;其 _ensure_conversation(最近对话兜底)行为不变。
  • socketio.emitconversation_changed/conversation_loaded 仍由 PUT load 发出;前端为纯 REST 轮询,不依赖这两个事件(已验证 initSocket 空实现)。

4. 非目标(本轮不做)

  • P2reasoning_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.pyASTRION_DATA_ROOT=/tmp/... 隔离):
    • old=5/new=3 无豁免 → 拒绝且文件不变;allow_shrink=True → 通过;
    • new≥old → 通过;load_conversation_by_id 同对话不触发 save_current_conversationmock 计数),异对话触发。
  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_idmsg_<uuid4>,存量抽查 100% 覆盖)→ 合并键可靠。
  • 合法"覆写语义"仅两处:检查点恢复(已豁免);浅压缩是 in-place 打标不缩减、手动压缩是新建对话——均不需要缩减豁免。

7.2 方案merge-on-save合并写代替覆写

crud_mixin.save_conversationexisting_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 守卫语义。