task-notification 到 attachment 回流序列图

这页回答什么

Claude Code 里的后台任务完成后,为什么不是直接“弹一条消息”结束,而是要走 task-notification -> queued command -> attachment -> next query context 这一大圈?这条回流链在代码里到底怎么落地?

关键文件:

  • src/tasks/LocalAgentTask/LocalAgentTask.tsx
  • src/tasks/RemoteAgentTask/RemoteAgentTask.tsx
  • src/utils/messageQueueManager.ts
  • src/query.ts
  • src/utils/attachments.ts

一句话结论

Claude Code 对后台任务完成的处理,不是“立刻把结果塞进当前模型上下文”,而是先把它标准化成一个排队中的 task-notification 命令,再在 下一次 query turn 的附件注入阶段 转成 queued_command attachment,最后作为新的 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_agent
  • status
  • summary
  • output_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)

顺序是:

  1. 先拍快照
  2. 用快照生成 attachment
  3. 真正成功消费后再 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_command attachment。

这是回流协议最核心的标准化步骤。


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-notificationprompt 在这里其实被同化了

在 attachment 转换层面:

  • prompt
  • task-notification

最后都走同一套:

  • queue item
  • queued_command attachment
  • push into toolResults

差别只在:

  • commandMode
  • origin
  • 具体 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 context

13. 更精确的系统分层

这条链拆开后,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_command attachment,最终作为正式上下文重新喂给模型。

这就是 Claude Code async / remote 回流统一性的真正落点。


下一步最值得继续追

  1. 单独拆 foreground -> background 热切换为什么必须重启 async lifecycle
  2. 把目前这些页整理成一张 Claude Code agent runtime 总览 MOC
  3. 如果还想继续向后看,就去找 env-runner/CCR 端如何消费 seed_bundle_file_id 和 bundle refs