从 QueryEngine 到 TaskNotification 的完整总图

这页回答什么

一次用户输入进入 Claude Code 后,是如何经过 QueryEngine、query 主循环、工具执行、后台任务系统,最后又以 task-notification 的形式回到主对话里的?

关键文件:

  • src/QueryEngine.ts
  • src/query.ts
  • src/utils/messageQueueManager.ts
  • src/utils/task/framework.ts
  • src/tasks/LocalAgentTask/LocalAgentTask.tsx
  • src/tasks/RemoteAgentTask/RemoteAgentTask.tsx
  • src/tools/AgentTool/AgentTool.tsx

一句话结论

QueryEngine 负责一轮对话的总生命周期,而 TaskNotification 机制负责把“异步世界里后来发生的事”重新注入这个对话生命周期。

换句话说:

  • 同步世界靠 query() 循环推进
  • 异步世界靠 task + queue + notification 回流

这两套机制合在一起,Claude Code 才能同时支持:

  • 普通工具调用
  • 后台 shell / background agent
  • remote session
  • 恢复后继续接收历史任务完成通知

1. QueryEngine 是会话级控制器

QueryEngine 的注释写得很直白:

  • one QueryEngine per conversation
  • 每次 submitMessage() 开始一个新 turn
  • messages / file cache / usage 等状态在 turn 之间持续存在

所以它不是一次 ask 的纯函数包装,而是:

一个拥有会话状态的控制器对象。

它持有:

  • mutableMessages
  • abortController
  • permissionDenials
  • readFileState
  • totalUsage
  • 每 turn 的 skill discovery 状态

这意味着 Claude Code 的“主会话”不是散装函数调用,而是明确有 stateful runtime 的。


2. ask() 只是 QueryEngine 的一次性包装器

ask() 本质上只是:

  1. new QueryEngine(...)
  2. engine.submitMessage(...)
  3. 把结果作为 async generator 往外吐

也就是说真正重要的不是 ask(),而是 submitMessage()

这个判断很关键,因为很多人看 CLI agent 源码时会把入口函数误当核心,实际上核心控制面已经被抽到类里了。


3. 一次前台用户输入,先进入 QueryEngine 的 turn 生命周期

submitMessage() 负责:

  • 读取配置
  • 初始化当前 turn 的上下文
  • 组装 system prompt
  • 组装 user context / coordinator context
  • 包装权限判定
  • 构建 processUserInputContext
  • 进入真正的 query loop

所以 QueryEngine 的职责不是“替代 query.ts”,而是:

在 query loop 外面,搭建一整圈会话与 turn 级脚手架。

可以粗分为三层:

A. 会话态

  • 累积 messages
  • usage
  • file cache

B. 本轮输入准备

  • prompt
  • system prompt
  • tools
  • agent definitions
  • permission policy

C. 主循环调用

  • query()
  • 吃回它产出的 SDK events

4. query() 只负责同步主循环,不负责长期异步收尾

虽然这一页没有把 query.ts 全展开,但从已有分析和周边代码能确认它负责的是:

  • 调模型
  • 解析 assistant 输出
  • 识别 tool_use
  • 分发到具体工具
  • tool_result 回灌消息流
  • 持续 loop

这个 loop 非常适合同步执行语义:

  • 模型说调用工具
  • 工具执行
  • 结果立刻回来
  • 继续推理

但一旦工具变成:

  • background shell
  • background agent
  • remote session

这个同步 loop 就不够了,因为结果不会马上回来。

所以系统必须在 query() 外再造一条“异步结果回流通道”。


5. 这条异步回流通道的核心是 message queue

enqueuePendingNotification() 很小,但地位非常高:

commandQueue.push({ ...command, priority: command.priority ?? 'later' })
notifySubscribers()

这说明系统里有一个统一的命令/消息队列,后台任务完成后不会直接粗暴打断当前逻辑,而是:

  1. 把 notification 放进队列
  2. 由订阅者或下一轮逻辑 drain
  3. 重新转成主会话可消费的输入

也就是说,任务系统和主对话循环之间的耦合点不是函数回调,而是:

一个统一消息队列。

这个设计比“直接调用主循环某个内部函数”更稳,因为它天然支持:

  • 顺序控制
  • 优先级
  • 跨模块解耦
  • 恢复时重放

6. Task framework 会扫描任务并生成标准 attachment

pollTasks() 做的事情很清晰:

  1. 从当前 AppState 扫任务
  2. generateTaskAttachments(state)
  3. 更新任务 offset / evict 信息
  4. 对每个 attachment 调 enqueueTaskNotification()

enqueueTaskNotification() 会构造一段标准 XML-ish 消息:

  • task_id
  • tool_use_id(可选)
  • task_type
  • output_file
  • status
  • summary

然后走:

enqueuePendingNotification({ value: message, mode: 'task-notification' })

这一步说明:

Task 层对主对话暴露的协议不是原始内部 state,而是一段标准化 notification payload。

这非常像一个内部事件总线协议。


7. LocalAgentTask / RemoteAgentTask 都会往这条通道上送消息

LocalAgentTask

本地 background agent 完成后,会通过:

  • enqueueAgentNotification()

把:

  • task id
  • output file
  • 状态
  • summary
  • result
  • usage
  • 可选 worktree

打包进 notification。

RemoteAgentTask

远端 session 完成后,会通过:

  • enqueueRemoteNotification()
  • 或 remote-review 专用 notification

同样送入 enqueuePendingNotification()

所以无论任务实际来自:

  • 本地 agent
  • 远端 agent
  • shell task
  • main session task

最后都会被归一成:

一个可被主会话消费的 task-notification 消息。

这就是架构上最漂亮的地方之一。


8. 为什么 tool_use_id 很关键

task notification 协议里保留 tool_use_id 这一点特别值钱。

因为这意味着主对话在收到异步完成通知时,不只是知道:

  • 某任务完成了

而且还能知道:

  • 这个完成事件对应的是哪一次工具调用

这样就能把异步完成结果重新挂回原始 tool-use 语义链。

换句话说,系统没有因为“任务异步化”而放弃因果关联。

这对:

  • transcript 可读性
  • UI 归因
  • 模型继续推理时的上下文一致性

都很重要。


9. 从 QueryEngine 视角看,task notification 是“后来到达的新输入”

虽然代码分散在多处,但设计上可以这样理解:

前半段

用户发起一轮输入:

  • QueryEngine 建立 turn
  • query loop 运行
  • 某工具返回 async_launched / remote_launched
  • 当前 turn 暂时结束

后半段

异步任务稍后完成:

  • task 层构造 notification
  • 丢进 message queue
  • QueryEngine / REPL / SDK bridge 在之后某个时机取走它
  • 把它作为一种系统输入再次送进会话

所以 task notification 的本质就是:

把“不在当前 turn 内完成的事情”重新折叠回对话系统里。

没有这条通道,异步任务就会变成对主会话不可见的孤岛。


10. 这也解释了为什么后台任务不是“UI 附件功能”

很多系统会把后台任务只做成一个侧边栏面板,完成后弹 toast。

Claude Code 这套设计更深一层:

  • 有 UI 展示,没错
  • 但更重要的是:完成事件要重新变成对话语义的一部分

因此 task-notification 不是可有可无的显示层小功能,而是:

让 agent 真正具备异步协作能力的协议层。

因为只有进入对话语义层,主 agent 才能“看见”另一个任务已经完成,并基于它继续思考。


11. 整体调用总图

可以把完整路径压成下面这张心智图:

同步路径

  1. 用户输入
  2. QueryEngine.submitMessage()
  3. query()
  4. assistant 产出 tool_use
  5. tool 执行
  6. 返回 tool_result
  7. query() 继续
  8. 当前 turn 结束

异步路径

  1. 用户输入
  2. QueryEngine.submitMessage()
  3. query()
  4. assistant 调用某异步工具(如 AgentTool background / remote)
  5. 工具立刻返回 launched 状态
  6. 当前 turn 先结束
  7. Task 系统继续在后台跑或轮询
  8. 完成后构造 task-notification
  9. enqueuePendingNotification() 入队
  10. 后续 turn / bridge / REPL drain 队列
  11. notification 回到主会话
  12. 主 agent 基于它继续推理或告知用户

这就是 Claude Code 把同步 loop 和异步 orchestration 拼起来的方式。


12. 这套设计的真正价值

我觉得最值得夸的点有三个:

A. QueryEngine 只管会话控制,不硬塞任务细节

所以主循环不会被后台任务逻辑污染得过于难看。

B. Task 层统一承接长期生命周期

不管是 shell、agent、remote,都能有一致的 output / status / notification 语义。

C. Notification 被建模成“消息”,不是“副作用”

这让异步任务天然能回流进 agent 对话,而不是停留在外围 UI 状态里。

这其实已经不是普通 CLI 小工具的设计了,而是比较成熟的 agent runtime 设计。


当前结论

从 QueryEngine 到 TaskNotification 的完整链路,本质上是在做一件事:

把同步推理循环与异步任务世界,用统一消息协议重新缝合起来。

其中:

  • QueryEngine 负责 conversation / turn 级生命周期
  • query() 负责同步工具推理循环
  • Task 负责长期任务生命周期
  • messageQueue 负责异步结果回流
  • task-notification 负责把后台世界重新翻译成主会话能理解的输入

这条链打通之后,Claude Code 才真正像一个能并行协作、能恢复、能继续上下文推进的 agent system。


下一步最值得继续追

  1. query.ts 里 notification/drain 是在哪个精确时点并入 message stream 的
  2. SDK / REPL / print mode 对 queued notification 的消费路径是否完全一致
  3. tool_use_id 在 transcript / UI / model continuation 中是如何被精确利用的