--- name: background_notification_pool description: 当需要讨论或修复后台子智能体/后台 run_command 完成通知机制时,应该索引本记忆 --- # 后台子智能体 / 后台 run_command 通知机制问题(待修复) ## 问题描述 后台子智能体和后台 `run_command` 在“主智能体停止输出后完成”的场景下,通知机制存在串行化问题: - 若同时有多个后台任务在运行,它们不会随完成逐个通知。 - 而是在所有后台任务全部完成后,按时间顺序一条一条地发送 user 消息。 - 且每发送一条 user 消息就要触发一次“工作 → 停止 → 再工作”的循环,导致通知被拆成多次停止输出。 ## 当前机制 运行期间(inline)和停止输出后(polling)是两条链路: ### 1. 运行期间(inline,当前正常) - 位置:`server/chat_flow_tool_loop.py` 中 `execute_tool_calls` 末尾。 - 调用: - `process_sub_agent_updates(..., inline=True)` - `process_background_command_updates(..., inline=True)` - 行为:每次工具循环结束时,把**当前所有已完成且未通知**的任务一次性插入 `messages`,不触发新任务。 - 结论:运行期间的批量通知已经是“池化”的,暂无问题。 ### 2. 停止输出后(polling,问题所在) - 位置:`server/chat_flow_task_main.py` 中 `handle_task_with_sender` 结尾。 - 调用: - `poll_sub_agent_completion(...)` - `poll_background_command_completion(...)` - 行为: - 两个轮询器各自由 `socketio.start_background_task(...)` 启动。 - 每轮循环只处理**一个**完成的通知。 - 处理完一个通知后,创建一个新的 chat task,然后 `return`。 - 下一个通知必须等待这个新 chat task 跑完,并再次走到 `handle_task_with_sender` 结尾重新 spawn 轮询器时才会发出。 ## 根因 后台任务管理器本身已经扮演了“后台池 + 通知池”的角色(任务完成但 `notified=False` 就是待通知状态)。 但轮询器是 **one-shot** 的:每次只消费一条通知就退出,导致通知被串行化到多次“工作 → 停止 → 再工作”循环中。 ## 讨论中的设计方案 统一为“通知池轮询器”: 1. 合并 `poll_sub_agent_completion` 和 `poll_background_command_completion` 为一个统一的 `poll_completion_notifications`。 2. 每次轮询时,同时从以下两处取出**所有**待通知项: - `sub_agent_manager.poll_updates()` - `background_command_manager.poll_updates(conversation_id=...)` 3. 把所有待通知项作为 user 消息插入对话历史(倾向保留为多条消息,而非合并成一条)。 4. 只创建 **一个**后续 chat task 来触发继续工作。 5. 轮询器返回,由这个新任务在结束时继续下一轮轮询。 ### 期望行为示例 - 3 个后台子智能体运行中,主智能体停止输出。 - 第 1 个完成 → 通知池有 1 条 → 插入 1 条 user 消息 → 触发 1 个新任务。 - 新任务处理期间,第 2、3 个完成 → 进入通知池。 - 新任务停止输出 → 轮询器发现通知池还有 2 条 → 一次性插入 2 条 user 消息 → 再触发 1 个新任务。 - 不再有后台任务时,轮询器直接退出。 ## 涉及文件 | 文件 | 角色 | |---|---| | `server/chat_flow_task_support.py` | 新增统一轮询器 `poll_completion_notifications`;可废弃旧的两个轮询函数。 | | `server/chat_flow_task_main.py` | 在 `handle_task_with_sender` 结尾,把分别 spawn 两个轮询器的逻辑替换为 spawn 一个统一轮询器。 | | `modules/sub_agent/manager.py` | 提供 `poll_updates()`,基于 `notified` 标志表示待通知状态。 | | `modules/background_command_manager.py` | 提供 `poll_updates()` 和 `has_pending_for_conversation()`。 | ## 待决策点 - 是否坚持把通知拆成多条 user 消息插入历史(推荐),还是合并为一条。 - 统一轮询器是否做成“持久单例”还是保持 one-shot batch(当前讨论倾向 one-shot batch,由新任务继续轮询)。 ## 状态 已实施(2026-06-24,方案 A + one-shot batch)。 ### 落地实现 - `server/chat_flow_task_support.py` 未改;新增逻辑集中在 `server/chat_flow_task_main.py`。 - 新增统一轮询器 `poll_completion_notifications`,替换并删除旧的 `poll_sub_agent_completion` / `poll_background_command_completion`。 - 新增 `_collect_pending_completion_notices`:每轮一次性从子智能体 + 后台命令两路取出所有待通知项(取出即就地标记 `notified` / `_announced_sub_agent_tasks`),按 `updated_at` 跨两路排序。 - 新增 `_has_pending_completion_work`:判断是否还有运行中/待通知的后台工作,决定轮询是否继续。 - 新增 `_build_sub_agent_notice_text`:抽出子智能体通知文本构建。 - `handle_task_with_sender` 结尾:保留 `sub_agent_waiting` 事件探测,但把「分别 spawn 两个轮询器」改为只 spawn **一个** `run_completion_poll`。**这也顺带消除了两个轮询器同时 `create_chat_task` 撞 `create_chat_task` 单工作区互斥(`models.py` RuntimeError)的隐患。** ### 方案 A 的「多条 + 单 task」如何落地 `create_chat_task` 只收单条 message,`handle_task_with_sender` 开头无条件 `add_conversation`。因此: - 一批取出 N 条:前 N-1 条为「前置通知」,最后 1 条作为后续 chat task 的触发消息。 - `_dispatch_completion_user_notice` 新增 `preceding_notices` 参数: - 通过 `_persist_and_echo_preceding_notice` 把前 N-1 条**写入对话历史**(`add_conversation`,落盘)+ **socketio 回显**(在线客户端即时可见),`starts_work=False`(渲染为完成态气泡,不起独立计时器)。 - 把 `preceding_user_notices` 塞进 `session_data`,传给后续任务。 - `server/tasks/models.py` 的 `_run_chat_task`:在补发触发 user_message 事件**之前**,先按顺序把 `preceding_user_notices` 逐条 `_append_event("user_message", ...)` 注入任务事件流——保证 REST 轮询客户端按时间顺序看到所有完成通知。 - 触发消息(最后 1 条)走原有 `auto_user_message_event` 链路,正常 `starts_work=True` 启动一轮工作。 ### 验证 - `python3 -m py_compile server/chat_flow_task_main.py server/tasks/models.py` 通过(注意本机 pycache 目录无写权限,需 `PYTHONPYCACHEPREFIX=$TMPDIR/pycache`)。 - `python3 -m unittest test.test_server_refactor_smoke` 6 用例通过。 - 前端未改:复用既有 `user_message` / `sub_agent_notice` 渲染链路(`messaging.ts` handleUserMessage + socketio `user_message`),dedup 与单通知场景一致。