task-notification 到 attachment 回流序列图
这页回答什么
Claude Code 里的后台任务完成后,为什么不是直接“弹一条消息”结束,而是要走
task-notification -> queued command -> attachment -> next query context这一大圈?这条回流链在代码里到底怎么落地?
关键文件:
src/tasks/LocalAgentTask/LocalAgentTask.tsxsrc/tasks/RemoteAgentTask/RemoteAgentTask.tsxsrc/utils/messageQueueManager.tssrc/query.tssrc/utils/attachments.ts
一句话结论
Claude Code 对后台任务完成的处理,不是“立刻把结果塞进当前模型上下文”,而是先把它标准化成一个排队中的
task-notification命令,再在 下一次 query turn 的附件注入阶段 转成queued_commandattachment,最后作为新的toolResults进入模型上下文。
也就是说:
后台完成事件先进入命令队列,再通过 attachment 协议重返对话。
这就是 local async 和 remote 最终统一的 re-entry 机制。
1. 起点:任务完成时先生成一段结构化文本
LocalAgentTask
enqueueAgentNotification(...) 会构造:
<task_notification><task_id><tool_use_id><output_file><status><summary>- 可选
<result> - 可选
<usage> - 可选
<worktree>
然后:
enqueuePendingNotification({
value: message,
mode: 'task-notification'
})RemoteAgentTask
enqueueRemoteNotification(...) 也做同样的事,只是内容更偏 remote:
task_type = remote_agentstatussummaryoutput_file
然后同样:
enqueuePendingNotification({
value: message,
mode: 'task-notification'
})所以 local async 和 remote 在“完成时往哪里投递”这一点上完全一致。
2. 第一层统一:都先进入 messageQueueManager 的全局命令队列
enqueuePendingNotification() 的行为非常简单直接:
commandQueue.push({ ...command, priority: command.priority ?? 'later' })重点有两个:
A. 它进的是统一命令队列
不是某个任务专属 inbox,而是整个进程共享的:
- user prompt
- task notification
- orphaned permission
- 其他 queued command
都在这里
B. 默认优先级是 later
意味着:
用户主动输入优先,后台任务通知不要抢主线程。
这就是为什么 task-notification 不会粗暴插队。
3. 第二层统一:真正消费它的不是 task 系统,而是 query()
关键代码在 src/query.ts turn 尾部。
每轮 query 在工具执行和普通 attachment 注入之后,会取一个:
const queuedCommandsSnapshot = getCommandsByMaxPriority(
sleepRan ? 'later' : 'next'
).filter(...)然后把这批 queued commands 送进:
getAttachmentMessages(
null,
updatedToolUseContext,
null,
queuedCommandsSnapshot,
[...messagesForQuery, ...assistantMessages, ...toolResults],
querySource,
)这说明:
task-notification真正重新进入模型上下文,不是 task runtime 自己完成的,而是query()在回合末尾主动 drain queue 时完成的。
这点非常关键。
4. agent scope:不是谁都能看到所有通知
query.ts drain 阶段还有一层 agent 过滤。
main thread
只消费:
cmd.agentId === undefined
subagent
只消费:
cmd.mode === 'task-notification'cmd.agentId === currentAgentId
这说明共享队列虽然是全局的,但消费是 按 agent scope 精确分流 的。
所以更准确的说法是:
队列是全局的,回流是定向的。
这保证:
- 主线程不会误吃别的 subagent 私信
- subagent 也不会拿到用户 prompt 流
5. 为什么这里叫 snapshot
query.ts 先拿:
queuedCommandsSnapshot
再生成 attachment
最后才 removeFromQueue(consumedCommands)。
顺序是:
- 先拍快照
- 用快照生成 attachment
- 真正成功消费后再 remove
这个顺序很像“先准备 payload,再确认 dequeue”,好处是:
attachment 生成和队列删除不会交叉污染,逻辑上更接近一次稳定的 turn-end drain。
6. attachments.ts 如何把 queued command 变成 attachment
getQueuedCommandAttachments() 明确只处理这两类 mode:
const INLINE_NOTIFICATION_MODES = new Set(['prompt', 'task-notification'])然后映射成:
{
type: 'queued_command',
prompt,
source_uuid: _.uuid,
imagePasteIds,
commandMode: _.mode,
origin: _.origin,
isMeta: _.isMeta,
}也就是说:
task-notification在 attachment 层已经不再是“任务系统内部事件”,而是被统一表达成一种queued_commandattachment。
这是回流协议最核心的标准化步骤。
7. attachment 不是旁路,而是直接进入 toolResults
在 query.ts 里:
for await (const attachment of getAttachmentMessages(...)) {
yield attachment
toolResults.push(attachment)
}这意味着 attachment 的地位非常高:
它不是 UI 附赠信息,而是会被并入
toolResults,作为下一次模型调用前的正式上下文输入。
所以 task-notification 最终变成的是:
- 一条 attachment message
- 同时也是 query loop 下一轮上下文的一部分
这就是为什么它能真正“叫醒”模型,而不只是界面提示一下。
8. consume 完成后才真正从队列删除
query.ts 之后会做:
const consumedCommands = queuedCommandsSnapshot.filter(
cmd => cmd.mode === 'prompt' || cmd.mode === 'task-notification'
)
removeFromQueue(consumedCommands)并且还会:
notifyCommandLifecycle(uuid, 'started')- query 正常返回后再
notifyCommandLifecycle(uuid, 'completed')
所以 queued command 不是 fire-and-forget 小纸条,而是带生命周期标记的命令对象。
这很像一个轻量任务总线,而不是单纯数组。
9. 为什么要绕这一圈,而不是直接把完成结果 append 到 messages
这是设计上最值得注意的地方。
如果后台任务完成时直接硬塞 messages,会有几个问题:
A. 当前 query 可能正在运行
此时直接改当前 messages 很容易破坏 turn 一致性。
B. local async / remote / hook / shell task 需要统一入口
不同来源都直接改消息历史,会让回流协议分裂。
C. 需要优先级和 agent scope
队列天生适合处理:
- 谁先消费
- 谁能看到
- 当前 turn 忙不忙
所以他们选的是:
异步事件先入队,主 query 在合适的 turn 边界统一吸收。
这是一个非常稳的工程决策。
10. task-notification 和 prompt 在这里其实被同化了
在 attachment 转换层面:
prompttask-notification
最后都走同一套:
- queue item
queued_commandattachment- push into
toolResults
差别只在:
commandModeorigin- 具体 payload 内容
所以从 attachments.ts 的视角看,task-notification 不是特殊大爷,而是 queued command 家族里的一个 mode。
11. Sleep tool 的特殊处理说明这套协议已经深入到 turn 调度
query.ts 里还有个细节:
getCommandsByMaxPriority(sleepRan ? 'later' : 'next')这说明:
- 如果这轮跑过 Sleep
- drain 行为会更保守
再结合 attachments.ts 里的注释:
- task-notification 如果不走 attachment 回流
- proactive agentic loop 里会导致 pending notifications 永远留在队列里
- Sleep 会被 0ms 立即唤醒,形成死循环
也就是说这条 reflow 不是附加设计,而是和调度稳定性直接相关。
12. 最简序列图
LocalAgentTask / RemoteAgentTask complete
→ build <task_notification> text
→ enqueuePendingNotification(mode='task-notification')
→ commandQueue
next query turn end
→ query.ts getCommandsByMaxPriority(...)
→ filter by agent scope
→ getAttachmentMessages(... queuedCommandsSnapshot ...)
→ attachments.ts getQueuedCommandAttachments()
→ queued_command attachment
→ yield attachment
→ toolResults.push(attachment)
→ removeFromQueue(consumedCommands)
next model call
→ attachment already sits inside query context13. 更精确的系统分层
这条链拆开后,Claude Code 的职责分层就很清楚:
Task runtime
负责:
- 检测任务完成
- 组装 task-notification payload
- 投递到队列
Queue layer
负责:
- 优先级
- agent scope
- 生命周期
- 延迟到 turn boundary 再消费
Attachment layer
负责:
- 把 queued command 统一转成 attachment message
- 标准化成模型可消费格式
Query loop
负责:
- 在安全时机 drain queue
- 把 attachment 注入本轮上下文
这四层配合起来,才形成完整 re-entry 协议。
当前结论
可以把整个回流机制压成一句:
后台任务不会直接写进当前消息流,而是先入全局命令队列,再由
query()在下一次安全的 turn 边界把它提升为queued_commandattachment,最终作为正式上下文重新喂给模型。
这就是 Claude Code async / remote 回流统一性的真正落点。
下一步最值得继续追
- 单独拆
foreground -> background热切换为什么必须重启 async lifecycle - 把目前这些页整理成一张 Claude Code agent runtime 总览 MOC
- 如果还想继续向后看,就去找 env-runner/CCR 端如何消费
seed_bundle_file_id和 bundle refs