开源贡献与故障复盘
第一个子 Agent 完成,不代表任务结束:一次多 Agent 完成信号的开源修复
复盘 Qwen Code 多 Agent 生命周期中 owner 隔离、排队和完成通知的边界修复。
当父 Agent 同时启动三个后台 Agent,其中一个率先完成时,它应该立刻总结结果,还是继续等待另外两个?
对人类来说,这个问题很简单:一个完成,不等于全部完成。
但在多 Agent 系统里,如果完成通知只告诉模型“Agent A 已完成”,却没有告诉它“还有几个 Agent 没完成”,父 Agent 就可能把局部完成错误地理解为整体完成。
这正是 Qwen Code Issue #8097 中暴露的问题之一。围绕这个问题,我提交了 PR #9018,为后台 Agent 的终态通知增加了按 owner 隔离的剩余任务信息。PR 经过多轮审查和 CI 验证,于 2026 年 8 月 24 日合并。
一、问题是怎么发生的?
假设父 Agent 同时派出三个后台 Agent:
- Agent A:分析数据库结构
- Agent B:检查 API 实现
- Agent C:查找测试覆盖
三个 Agent 并行执行,A 最先完成。
原来的完成通知大致如下:
<task-notification>
<task-id>agent-a</task-id>
<status>completed</status>
<summary>Agent "分析数据库结构" completed.</summary>
</task-notification>
这条消息准确描述了 Agent A 的状态,但没有回答另一个更重要的问题:
Agent B 和 Agent C 是否仍在运行?
父 Agent 看到的是一个语义完整的“完成通知”。如果它没有额外维护可靠的任务集合,就可能立即开始生成最终答案。
整个过程类似于:
sequenceDiagram
participant P as 父 Agent
participant A as 子 Agent A
participant B as 子 Agent B
participant C as 子 Agent C
P->>A: 启动任务
P->>B: 启动任务
P->>C: 启动任务
A-->>P: completed
Note over P: 误以为所有任务已结束
P-->>P: 开始生成最终答案
B-->>P: completed
C-->>P: completed
Note over P: 后续结果可能已来不及纳入
问题不在于 A 的通知是错误的,而在于它只表达了“单个任务的终态”,却被父 Agent 当成了“整组任务的终态”。
二、单任务状态和整体协调状态不是一回事
在并发系统中,需要区分两种信息:
| 信息 | 回答的问题 |
|---|---|
| 单任务状态 | Agent A 是否已经完成? |
| 协调整体状态 | 与 A 属于同一组的其他 Agent 是否都已完成? |
原来的通知只包含第一类信息:
<status>completed</status>
但父 Agent 真正需要的是:
Agent A 已完成
+
同组还有 2 个 Agent 未完成
因此,PR 为通知增加了两个字段:
<remaining>2</remaining>
<all-terminal>false</all-terminal>
当最后一个 Agent 完成时,通知变成:
<remaining>0</remaining>
<all-terminal>true</all-terminal>
父 Agent 由此可以区分:
- 某个 Agent 已经完成;
- 所有相关 Agent 都已经完成。
这两个字段没有改变原有通知结构,而是以附加字段的方式扩展协议。
三、为什么不能直接统计所有运行中的 Agent?
最直接的实现似乎是:
const remaining = agents.filter(
(agent) => agent.status === 'running',
).length;
但这个计数在多会话、多父 Agent 或嵌套 Agent 场景下并不可靠。
假设系统里同时存在两组任务:
父 Agent P1
├── A1
└── A2
父 Agent P2
├── B1
└── B2
A1 完成时,P1 只关心 A2,不能把 B1 和 B2 计入自己的剩余任务数。
因此,计数需要带有作用域。
PR 使用 parentAgentId 表示 Agent 的 owner,并只统计与当前终态 Agent 属于同一 owner 的任务。核心逻辑可以简化为:
const ownerId = entry.parentAgentId ?? null;
let remaining = 0;
for (const candidate of agents.values()) {
const sameOwner =
(candidate.parentAgentId ?? null) === ownerId;
const nonTerminal =
candidate.status === 'running' ||
candidate.status === 'paused';
if (
candidate.isBackgrounded &&
sameOwner &&
nonTerminal
) {
remaining++;
}
}
这里还有一个容易忽视的细节:
parentAgentId ?? null
parentAgentId 的类型允许 string | null | undefined。如果不做归一化,显式的 null 和未设置的 undefined 会被视为不同 owner。
于是两个实际上都属于顶层会话的 Agent,可能被错误地分到两个组里。
因此,?? null 不是格式偏好,而是 owner 语义的一部分。
四、第一版方案为什么仍然不完整?
最初的思路是从 Agent registry 中统计同 owner 的未终结任务。
但自动审查指出了一个关键问题:
一个后台 Agent 在正式注册之前,也可能已经成为“确定会启动的未完成工作”。
后台 Agent 从工具调用到正式运行,并不是一步完成的。它通常会经历以下阶段:
| 阶段 | 是否已经注册 | 是否属于未完成工作 |
|---|---|---|
| 等待并发槽位 | 否 | 是 |
| 已预留槽位,正在初始化 | 否 | 是 |
| 已注册并运行 | 是 | 是 |
| 完成、失败或取消 | 是 | 否 |
如果只统计 registry 中的 Agent,就会遗漏前两种状态。
考虑下面的执行顺序:
sequenceDiagram
participant P as 父 Agent
participant R as Registry
participant A as Agent A
participant B as Agent B
P->>A: 启动
P->>B: 启动
Note over B,R: B 获得槽位,正在初始化<br/>尚未 register
A-->>R: completed
R-->>P: remaining = 0
Note over P: 错误地认为全部完成
B->>R: register
B-->>B: 开始执行
A 完成时,B 尚未出现在 registry 中。仅统计已注册 Agent 会得到:
<remaining>0</remaining>
<all-terminal>true</all-terminal>
但 B 明明已经处于启动流程中。
这是一种典型的“注册窗口”问题:系统中的真实生命周期,比 registry 中可见的生命周期更长。
五、最终方案:统计完整的启动生命周期
为了解决注册前的可见性缺口,最终实现除了统计已注册 Agent,还会统计两类 outstanding launch:
- 等待并发槽位的 waiter;
- 已获得槽位、但尚未完成注册的 reservation。
为此,owner 信息需要继续向调度结构传播:
interface BackgroundSlotWaiter {
model?: string;
ownerId: string | null | undefined;
// ...
}
interface BackgroundSlotClaim {
model: string | undefined;
ownerId: string | null | undefined;
}
计算剩余任务时,再把同 owner 的启动任务加进去:
remaining +=
getOutstandingBackgroundLaunchCount(ownerId);
简化后的统计模型是:
remaining
=
同 owner 的 running Agent
+
同 owner 的 paused Agent
+
同 owner 的 queued launch
+
同 owner 的 reserved launch
这次修改的关键,并不是多加了两个 XML 标签,而是重新明确了“未完成工作”的边界:
一个任务从进入调度队列开始,就已经是需要父 Agent 等待的工作,而不是等到注册完成后才算。
六、为什么 owner 必须跟着 reservation 和 waiter 一起传递?
原来的 slot reservation 主要服务于并发限制,因此只需要记录模型信息:
Map<symbol, model>
但当 reservation 开始参与 owner-scoped 计数后,它就承担了新的语义:
Map<symbol, { model, ownerId }>
这是一种常见的架构变化:
- 起初,一个数据结构只负责资源控制;
- 后来,它又参与生命周期协调;
- 于是原本足够的字段不再足够。
如果 owner 只存在于已注册 Agent 上,就无法判断排队者和预留者属于哪一个父级。
因此,owner 信息必须从启动入口一直传递到:
Agent 启动请求
↓
tryReserveBackgroundSlot
↓
waitForBackgroundSlot
↓
BackgroundSlotWaiter / BackgroundSlotClaim
↓
register
只要中间任何一步丢失 owner,最终计数就可能跨组污染,或者漏掉本组任务。
七、回归测试应该覆盖什么?
这个 PR 的测试重点不是验证 XML 中“出现了两个字段”,而是验证这些字段在不同生命周期顺序下仍然正确。
1. 同 owner 的完成顺序
两个同 owner Agent 依次结束时:
第一个通知:remaining = 1
最后一个通知:remaining = 0
2. owner 隔离
其他 owner 的 Agent 不应进入当前计数。
P1 的 A 完成
P2 的 B 仍在运行
A 的 remaining 不应包含 B
3. 前台 Agent 排除
前台 Agent 通过同步工具结果返回,不依赖后台终态通知,因此不能进入后台任务计数。
4. 关闭和取消顺序
批量取消多个 Agent 时,通知中的剩余数量应该逐步下降,并在最后一条通知中归零。
5. 排队、预留和注册之间的转换
同一个启动任务从 waiter 变成 reservation,再变成 registered Agent 时,不能被重复计数,也不能暂时从计数中消失。
queued: remaining + 1
reserved: remaining + 1
registered: remaining + 1
terminal: remaining + 0
这可以看作一种守恒关系:状态发生变化,但同一个未完成任务对总数的贡献始终是 1。
6. null owner 的真实生产形态
审查还指出,实际顶层后台启动通常使用 ownerId = null,而早期测试主要使用字符串 owner。
这会留下一个测试盲区:代码现在是正确的,但未来有人把判断从:
ownerId !== undefined
改成:
ownerId != null
字符串 owner 的测试仍然通过,但生产环境中的 null owner 会被排除。
最终补充了针对顶层 reservation 和 queued waiter 的测试,并通过临时变异实现确认:如果错误过滤 null,新测试确实会失败。
这类测试比普通覆盖率更有价值,因为它证明测试能够识别目标回归,而不只是执行过相关代码。
总结
这次修复解决的核心问题可以概括为:
父 Agent 收到一个子 Agent 的完成通知时,无法判断这是局部完成还是整体完成。
最终方案为后台 Agent 的终态通知增加了按 owner 隔离的:
<remaining>...</remaining>
<all-terminal>...</all-terminal>
同时,计数范围不再局限于已注册 Agent,而是覆盖:
- queued launch;
- reserved launch;
- running Agent;
- paused Agent。
这个 PR 没有建立完整的 delegation batch,也没有解决启动撤回后的纠正通知。但它建立了一个更可靠的基础:父 Agent 终于能够区分“一个任务结束”和“相关任务全部结束”。
在多 Agent 系统中,真正困难的往往不是让 Agent 并行运行,而是让它们对彼此的生命周期形成一致认识。
第一个子 Agent 完成,只是一个事件。
是否可以结束,需要另一层协调状态来回答。