foreground 到 background 热切换机制
这页回答什么
Claude Code 里的同步 subagent,为什么运行到一半还能被 background 化?更关键的是,为什么 background 后不是接着原来的 iterator 跑,而是要显式结束 foreground iterator,再重新启动一条 async lifecycle?
关键文件:
src/tools/AgentTool/AgentTool.tsxsrc/tools/AgentTool/agentToolUtils.tssrc/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({ ... })拿到:
taskIdbackgroundSignalcancelAutoBackground
对应的 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_launched13. 这套设计解决了什么问题
至少解决了这些工程问题:
A. 不需要把 async iterator 做成可迁移对象
直接关闭旧 iterator,重新起一个更稳。
B. 前台和后台清理职责不打架
谁启动谁收尾,边界清楚。
C. 用户体验平滑
长任务可中途退到后台,不必整轮卡死。
D. 结果仍连续
因为 agentMessages 和 progress 基线被带过去了。
当前结论
foreground → background 热切换的本质可以压成一句:
Claude Code 不是把同一个前台
runAgent()iterator 直接“搬到后台”,而是先有序关闭前台执行壳,再保留已产生的 transcript/progress 基线,用同一份 agent 规格重新启动一条 async lifecycle。
这就是为什么代码里会看到:
backgroundSignalagentIterator.return()- 再次
runAgent({ isAsync: true }) - 最后转成标准 async notification 回流
这不是重复劳动,是为了把前台壳和后台壳严格分开。
下一步最值得继续追
- 把现有这些页面再汇总成一张 Claude Code agent runtime 总索引
- 如果还想继续外扩,就去找 env-runner / CCR 后端如何恢复 bundle seed
- 顺手做一轮旧页面的 sources/frontmatter 卫生整理