开源贡献与故障复盘

第一个子 Agent 完成,不代表任务结束:一次多 Agent 完成信号的开源修复

复盘 Qwen Code 多 Agent 生命周期中 owner 隔离、排队和完成通知的边界修复。

Multi-AgentQwen Code生命周期

当父 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:

  1. 等待并发槽位的 waiter;
  2. 已获得槽位、但尚未完成注册的 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 完成,只是一个事件。

是否可以结束,需要另一层协调状态来回答。

相关链接

陈涛 · Agent Application Developer

杭州 · 2026