---
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 与单通知场景一致。
