foreground 到 background 热切换机制

这页回答什么

Claude Code 里的同步 subagent,为什么运行到一半还能被 background 化?更关键的是,为什么 background 后不是接着原来的 iterator 跑,而是要显式结束 foreground iterator,再重新启动一条 async lifecycle?

关键文件:

  • src/tools/AgentTool/AgentTool.tsx
  • src/tools/AgentTool/agentToolUtils.ts
  • src/tasks/LocalAgentTask/LocalAgentTask.tsx

一句话结论

foreground background 不是简单改一个任务状态位,而是一次 execution shell 切换。前台执行壳负责“当前 tool 调用内 inline 消费 runAgent() 输出”,后台执行壳负责“脱离当前 tool 调用、由 LocalAgentTask 生命周期托管”。两者职责不同,所以 Claude Code 选择 终止前台 iterator,再以 isAsync: true 重启一条后台生命周期

也就是说:

  • 不是同一条执行壳继续跑
  • 而是同一份 agent 规格被重新挂到另一种 runtime 外壳上

1. foreground agent 一开始就被注册成“可背景化任务”

同步路径并不是完全不进 task 系统。

AgentTool.tsx 里,sync 分支会先:

registerAgentForeground({ ... })

拿到:

  • taskId
  • backgroundSignal
  • cancelAutoBackground

对应的 LocalAgentTask 状态是:

  • status: 'running'
  • isBackgrounded: false

这说明 sync agent 从一开始就不是“纯局部临时流程”,而是:

先以 foreground task 身份注册,方便后续随时切壳。


2. foreground loop 的核心结构:next()backgroundSignal 竞争

同步路径里,会先拿:

const agentIterator = runAgent(...)[Symbol.asyncIterator]()

然后每轮 loop 都做:

const nextMessagePromise = agentIterator.next()
const raceResult = await Promise.race([
  nextMessagePromise,
  backgroundPromise,
])

所以 foreground 模式其实一直处于一种双通道等待:

  • agent 自己继续产出消息
  • 或者外部发来“转后台”信号

这就是热切换的触发点。


3. 背景化触发并不神秘,本质就是把 task 的 isBackgrounded 设成 true

无论是:

  • 用户 Ctrl+B
  • auto-background timeout

最后都会走到类似逻辑:

backgroundAgentTask(taskId, ...)

或者 registerAgentForeground() 里的自动定时器分支。

其核心动作很朴素:

  • 把 task 的 isBackgrounded 改成 true
  • resolve backgroundSignal

这意味着:

热切换的“中断前台 loop”能力,不是来自 runAgent() 本身,而是来自 task state + signal 这层外部协调机制。


4. 真正关键的一步:先 agentIterator.return(),不是直接放着不管

raceResult.type === 'background' 后,代码不会直接另起后台,然后让原 iterator 自生自灭。

它先做:

await Promise.race([
  agentIterator.return(undefined).catch(() => {}),
  sleep(1000)
])

注释写得很清楚,目的就是让 foreground iterator 的 finally 跑掉,释放:

  • MCP connections
  • session hooks
  • prompt cache tracking
  • 其他 query/runAgent 相关清理

这一步非常重要。

也就是说:

Claude Code 不是把 foreground iterator “暂停”,而是尽量 有序关闭 它。

因为 JS async iterator 在这里并没有一个可安全迁移到另一外壳的“冻结中间态”协议。


5. 为什么不能直接复用原 iterator

从代码结构看,至少有 4 个现实原因。

A. 前台壳和后台壳的责任完全不同

foreground 壳负责:

  • 当前 tool 调用内消费消息
  • 实时把结果回传给父 turn
  • 背景提示 UI
  • foreground task 注册/注销

background 壳负责:

  • 脱离当前 tool 调用继续执行
  • 更新 LocalAgentTask.progress
  • terminal 后发 notification
  • 独立清理和收尾

所以不是换个 flag 就能复用。

B. foreground iterator 绑定了当前 tool call 的控制流

它的输出正在被当前 AgentTool.call() 消费。 一旦 tool 要立即返回 async_launched,原 iterator 就不该再继续归这个调用栈管。

C. 清理语义需要明确边界

如果不 return() 前台 iterator:

  • 旧连接可能悬着
  • hooks / prompt cache 跟踪可能泄漏
  • 两套 consumer 可能争用同一个生成器

D. 后台路径需要 isAsync: true 的独立上下文

后台重启时明确传:

runAgent({
  ...runAgentParams,
  isAsync: true,
  override: { agentId, abortController }
})

说明后台不是简单续跑,而是切换到了“真正 async agent”的执行配置。


6. 后台不是裸跑,而是重挂到完整 async lifecycle

切后台后,代码并不是只 runAgent() 一下,而是在一个独立 closure 里完整跑一遍后台壳:

  • 建 tracker
  • 重放已有 agentMessages 到 tracker,恢复 progress 基线
  • for await 新的 runAgent({ isAsync: true })
  • 持续 updateAsyncAgentProgress(...)
  • terminal 后 finalizeAgentTool(...)
  • completeAsyncAgent(...)
  • enqueueAgentNotification(...)

这其实就是手工内联了一份 runAsyncAgentLifecycle() 风格的逻辑。

所以更准确地说:

foreground background 不是“继续同一次同步执行”,而是“拿已积累的 transcript 状态作基线,接着进入标准 async task lifecycle”。


7. agentMessages 被保留下来,是热切换不断层的关键

前台跑到一半时已经积累了:

const agentMessages: MessageType[] = []

切后台后,这个数组不会丢。

后台 closure 会:

  • 先把已有 agentMessages 喂给 progress tracker
  • 新消息继续 push 到同一个 agentMessages
  • 最终 finalizeAgentTool(agentMessages, ...)

所以切壳虽然会重启一条 async runAgent(),但 结果拼接层 保持连续。

这点很关键。

否则前台阶段的产出会在后台结果里消失。


8. 为什么要重新初始化 progress tracker

后台切换时会:

  • 新建 tracker
  • 用已有 agentMessages 回放一遍

原因很直接:

后台壳自己的 progress 维护不能依赖前台 loop 内部临时状态,它需要一个可独立延续的统计基线。

这也再次说明切换的是外壳,不是简单把旧 loop 某个局部变量搬过去。


9. sync 路径返回 async_launched,说明这次切换本质上改变了 tool contract

切后台后,当前 AgentTool.call() 会立刻返回:

{
  status: 'async_launched',
  agentId,
  outputFile,
}

注意这和原本 sync contract 已经不是一回事了。

原本 sync 是:

  • 当场等到 completed

热切换后变成:

  • 当前只返回句柄
  • 最终结果以后走 task-notification 回流

所以这不是实现细节,而是 用户可见协议切换

既然协议变了,执行壳变掉也就合理了。


10. runAsyncAgentLifecycle() 也能反证两套壳的差异

agentToolUtils.ts 里的 runAsyncAgentLifecycle() 非常清楚地表达了后台壳应该做什么:

  • 任务进度更新
  • summarization
  • terminal 状态切换
  • worktree cleanup
  • enqueue notification

而 foreground loop 关心的是:

  • 当前 tool call 的流式消费
  • 进度事件回传给父调用
  • 可能切后台
  • 最终若未切后台则 inline 返回 completed

所以两者不是同一个函数多传个布尔值就能优雅统一的关系。

它们确实是两种 lifecycle shell。


11. 为什么说它是“execution shell 切换”,而不是“execution engine 切换”

这里还有个细分值得说清。

没变的

  • 核心 agent 定义
  • prompt / tools / worktree / querySource 等运行规格
  • 本地执行引擎本质上还是 runAgent()

变了的

  • 谁来消费 runAgent() 输出
  • 谁维护任务状态
  • 谁负责最终通知
  • 当前 tool call 是否继续阻塞

所以变的是:

外层 lifecycle shell

而不是底层 execution engine。

这个表述比“切了另一套 agent runtime”更准确。


12. 最简序列图

foreground sync path
  → registerAgentForeground()
  → agentIterator = runAgent()[Symbol.asyncIterator]()
  → loop: race(nextMessage, backgroundSignal)
 
user/auto triggers background
  → task.isBackgrounded = true
  → resolve backgroundSignal
  → foreground path sees background event
  → agentIterator.return()
  → rebuild tracker from existing agentMessages
  → start new runAgent({ isAsync: true })
  → background lifecycle updates task progress
  → completeAsyncAgent / enqueueAgentNotification
  → current tool call returns async_launched

13. 这套设计解决了什么问题

至少解决了这些工程问题:

A. 不需要把 async iterator 做成可迁移对象

直接关闭旧 iterator,重新起一个更稳。

B. 前台和后台清理职责不打架

谁启动谁收尾,边界清楚。

C. 用户体验平滑

长任务可中途退到后台,不必整轮卡死。

D. 结果仍连续

因为 agentMessages 和 progress 基线被带过去了。


当前结论

foreground background 热切换的本质可以压成一句:

Claude Code 不是把同一个前台 runAgent() iterator 直接“搬到后台”,而是先有序关闭前台执行壳,再保留已产生的 transcript/progress 基线,用同一份 agent 规格重新启动一条 async lifecycle。

这就是为什么代码里会看到:

  • backgroundSignal
  • agentIterator.return()
  • 再次 runAgent({ isAsync: true })
  • 最后转成标准 async notification 回流

这不是重复劳动,是为了把前台壳和后台壳严格分开。


下一步最值得继续追

  1. 把现有这些页面再汇总成一张 Claude Code agent runtime 总索引
  2. 如果还想继续外扩,就去找 env-runner / CCR 后端如何恢复 bundle seed
  3. 顺手做一轮旧页面的 sources/frontmatter 卫生整理