从 QueryEngine 到 TaskNotification 的完整总图
这页回答什么
一次用户输入进入 Claude Code 后,是如何经过 QueryEngine、query 主循环、工具执行、后台任务系统,最后又以 task-notification 的形式回到主对话里的?
关键文件:
src/QueryEngine.tssrc/query.tssrc/utils/messageQueueManager.tssrc/utils/task/framework.tssrc/tasks/LocalAgentTask/LocalAgentTask.tsxsrc/tasks/RemoteAgentTask/RemoteAgentTask.tsxsrc/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 的纯函数包装,而是:
一个拥有会话状态的控制器对象。
它持有:
mutableMessagesabortControllerpermissionDenialsreadFileStatetotalUsage- 每 turn 的 skill discovery 状态
这意味着 Claude Code 的“主会话”不是散装函数调用,而是明确有 stateful runtime 的。
2. ask() 只是 QueryEngine 的一次性包装器
ask() 本质上只是:
- new
QueryEngine(...) - 调
engine.submitMessage(...) - 把结果作为 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()这说明系统里有一个统一的命令/消息队列,后台任务完成后不会直接粗暴打断当前逻辑,而是:
- 把 notification 放进队列
- 由订阅者或下一轮逻辑 drain
- 重新转成主会话可消费的输入
也就是说,任务系统和主对话循环之间的耦合点不是函数回调,而是:
一个统一消息队列。
这个设计比“直接调用主循环某个内部函数”更稳,因为它天然支持:
- 顺序控制
- 优先级
- 跨模块解耦
- 恢复时重放
6. Task framework 会扫描任务并生成标准 attachment
pollTasks() 做的事情很清晰:
- 从当前 AppState 扫任务
generateTaskAttachments(state)- 更新任务 offset / evict 信息
- 对每个 attachment 调
enqueueTaskNotification()
enqueueTaskNotification() 会构造一段标准 XML-ish 消息:
task_idtool_use_id(可选)task_typeoutput_filestatussummary
然后走:
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. 整体调用总图
可以把完整路径压成下面这张心智图:
同步路径
- 用户输入
QueryEngine.submitMessage()query()- assistant 产出
tool_use - tool 执行
- 返回
tool_result query()继续- 当前 turn 结束
异步路径
- 用户输入
QueryEngine.submitMessage()query()- assistant 调用某异步工具(如 AgentTool background / remote)
- 工具立刻返回 launched 状态
- 当前 turn 先结束
- Task 系统继续在后台跑或轮询
- 完成后构造 task-notification
enqueuePendingNotification()入队- 后续 turn / bridge / REPL drain 队列
- notification 回到主会话
- 主 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。
下一步最值得继续追
query.ts里 notification/drain 是在哪个精确时点并入 message stream 的- SDK / REPL / print mode 对 queued notification 的消费路径是否完全一致
tool_use_id在 transcript / UI / model continuation 中是如何被精确利用的