AgentTool 清理顺序与泄漏防线

这页回答什么

AgentTool 的 sync / async / foregroundbackground 路径里,资源清理到底谁负责?代码里有哪些明确的防泄漏设计?还有哪些地方只是“尽量降低风险”,并不等于彻底无泄漏?

关键文件:

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

一句话结论

AgentTool 这一层已经明显在按“状态先落地,重清理后置”的思路写:

  • task status 先完成迁移,避免 UI / TaskOutput 永远卡住
  • 通知晚一点也行,但不能阻塞状态完成
  • worktree cleanup / transcript classifier / summarization stop 都被当成可慢、可挂的附属步骤
  • foregroundbackground 专门做了一次 iterator.return() + finally 转交,目的就是防止两套壳同时持有资源

所以它的设计重点不是“绝不出问题”,而是:

就算某些清理或 embellishment 卡住,也尽量别把主任务状态拖死。


1. 最核心的防线:先 completeAsyncAgent(),再做慢动作

无论是:

  • async-from-start 路径(runAsyncAgentLifecycle()
  • foreground 中途 background 的 closure

代码都在强调同一件事:

completeAsyncAgent(agentResult, rootSetAppState)

必须先发生。

然后才做:

  • classifyHandoffIfNeeded(...)
  • cleanupWorktreeIfNeeded() / getWorktreeResult()
  • enqueueAgentNotification(...)

源码注释已经点明:

这些 API call / git exec 可能 hang,不能 gate 住 task status transition。

这说明 Claude Code 把风险分成两类:

不能卡住的

  • task 从 running completed / failed / killed
  • TaskOutput(block=true) 解锁
  • UI 不再显示任务还在跑

可以慢一点的

  • handoff classifier
  • worktree 检查与清理
  • 结果通知装饰

这是整个清理设计最重要的一条主线。


2. summarization 的 stop 句柄是“每个壳一份”,不是共用

sync foreground 路径里有:

  • stopForegroundSummarization

background closure 里单独有:

  • stopBackgroundedSummarization

runAsyncAgentLifecycle() 里又有自己的:

  • stopSummarization

这不是代码重复,而是明确在切生命周期边界。

这说明什么

每个 execution shell:

  • 自己启动 summarization
  • 自己停止 summarization
  • 不和另一壳共享 stop 句柄

这样可以避免:

  • foreground stop 误伤 background summarizer
  • background finally 再 stop 一次前台 summarizer
  • 某个 closure 持有过期 stop handle

源码里甚至直接写了:

NOT shared with the backgrounded closure

这基本就是在明说:

summarization 生命周期是按壳隔离管理的。


3. foregroundbackground 的关键清理动作:agentIterator.return()

前台热切后台时,最重要的防泄漏动作是:

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

代码注释明确给出清理目标:

  • MCP connections
  • session hooks
  • prompt cache tracking
  • 其它 runAgent() / query() finally 里的资源

所以这里的设计不是“暂停前台 iterator”,而是:

尽可能让旧 iterator 正常走 finally,再把执行权交给后台壳。

这就是 foregroundbackground 不容易双持资源的核心原因。


4. backgroundPromise 放在循环外,是一个小但重要的泄漏修复点

AgentTool.tsx 里有非常直白的注释:

如果每轮 loop 都对同一个 pending backgroundSignal.then() 一次,就会在 agent 生命周期里不断累积 reaction callback。

所以它改成:

  • 在 loop 外只创建一次 backgroundPromise
  • loop 内只 Promise.race([nextMessagePromise, backgroundPromise])

这意味着它防的不是业务错误,而是更底层的:

长期运行 sync agent 时,对同一 pending promise 的监听器累积。

这个点很小,但说明作者确实在看“隐性资源增长”。


5. wasBackgrounded 是清理职责转交的开关位

sync foreground finally 里有几条关键逻辑:

  • 如果 wasBackgrounded === true
    • 不清 dumpState
    • 不做 worktree cleanup
    • 不把自己当 terminal sync task 去发 SDK 完成事件

因为这些职责已经转交给 background closure 了。

这意味着

wasBackgrounded 不是 UI 标记,而是:

cleanup ownership handoff bit

没有这个位,就容易出现:

  • foreground finally 清一次
  • background finally 再清一次
  • worktree 被提早删掉
  • dumpState / skills 被重复清掉

所以这其实是热切换路径避免 double-cleanup 的总闸门。


6. worktree cleanup 被刻意放在“通知之前”

在 async/background terminal 路径里,代码顺序大致是:

  1. complete / fail / kill task status
  2. cleanupWorktreeIfNeeded()
  3. enqueueAgentNotification(...),并把 worktreePath/worktreeBranch 带进去

这样设计的原因不是一定要先删目录,而是:

通知文本需要包含“最终 worktree 结论”。

具体语义:

  • 没改动 → worktree 可删除
  • 有改动 → worktree 要保留,并把 path/branch 传给通知

所以 worktree cleanup 同时承担两件事:

  • 回收隔离环境
  • 产出 terminal metadata

7. 但 worktree cleanup 仍然是潜在风险点

虽然主状态已经先完成,但 cleanupWorktreeIfNeeded() 依然可能:

  • git hang
  • 文件系统卡住
  • hook-based worktree 无法可靠判定变化

代码里的处理方式是:

  • 不让它阻塞 task status
  • 但仍然 await 它,以便通知里能带 worktree 信息

所以这条风险被降级了,但没有被消灭。

准确说法应该是:

Claude Code 已经避免“任务假死”,但没有完全避免“cleanup 慢/挂”。


8. kill 路径的一个明显设计:状态迁移优先于 git cleanup

无论 runAsyncAgentLifecycle() 还是 backgrounded closure,AbortError 路径都强调:

killAsyncAgent(taskId, rootSetAppState)

先做,再 cleanup worktree。

killAsyncAgent() 本身会:

  • abortController.abort()
  • unregisterCleanup?.()
  • 状态改成 killed
  • 清掉 controller / cleanup handle

这里的意思非常明确:

先把任务从“运行中”摘掉,剩下的清理慢慢做。

所以 kill 路径的主目标是:

  • 不让 UI / task system 继续认为任务还活着
  • 之后再去处理 worktree、partial result、notification

9. enqueueAgentNotification() 有 notified 原子门,防重复消息

LocalAgentTask.tsx 里,enqueueAgentNotification() 会先原子检查:

  • 如果 task.notified 已经是 true,就直接跳过
  • 否则先标记,再入队

这说明通知系统也在防重入,特别是:

  • TaskStopTool
  • abort catch
  • terminal path
  • bulk kill / markAgentsNotified

这些路径之间可能重叠。

所以它的目标是:

允许多处“想发通知”,但最终只发一次。

这也是一种资源/状态一致性防线。


10. killAsyncAgent() 会清 abortController 和 unregisterCleanup

这是个很值得注意的小点。

kill 后任务状态里会被清掉:

  • abortController
  • unregisterCleanup
  • selectedAgent

意义是:

  • 避免 task object 长期持有已经无用的控制器/cleanup closure
  • 减少 resume / panel / retain 状态里继续挂老引用
  • 明确 terminal task 的轻量化状态

这不是“彻底内存管理”,但确实是显式释放引用的动作。


11. runAsyncAgentLifecycle() 的 finally 很克制,只做全局映射清理

runAsyncAgentLifecycle() finally 里只做:

  • clearInvokedSkillsForAgent(agentIdForCleanup)
  • clearDumpState(agentIdForCleanup)

它不碰:

  • task status
  • notification
  • worktree cleanup

因为这些必须在 try/catch 的 terminal 分支里带着上下文做。

这其实是个不错的分层:

finally 负责

  • 全局 side-map / registry 清理

try/catch terminal branch 负责

  • status
  • notification
  • worktree metadata
  • partial result

所以 finally 不是大杂烩,而是“无论成功失败都该清掉的 side registry”。


12. 当前看到的防泄漏设计清单

已明确存在的防线

  • backgroundPromise 循环外创建,避免 .then() 累积
  • foregroundbackground 先 agentIterator.return()
  • foreground / background summarization stop handle 分离
  • wasBackgrounded 防 double-cleanup
  • task status 先 terminal,再做慢 cleanup
  • enqueueAgentNotification()notified 防重复通知
  • killAsyncAgent() 清掉 abort / cleanup 引用
  • finally 清 clearInvokedSkillsForAgent + clearDumpState

已明确承认但未彻底消灭的风险

  • git/worktree cleanup 仍可能 hang
  • transcript classifier 仍可能慢
  • agentIterator.return() 只等 1 秒,属于 best-effort,不是强保证
  • hook-based worktree 无法可靠判断变化,只能保留

13. 目前最值得继续追的未尽问题

A. runAgent() finally 里到底还释放了什么

目前只从注释知道有:

  • MCP
  • session hooks
  • prompt cache tracking

可以继续进 src/tools/AgentTool/runAgent.ts 看 finally 块,把真实 cleanup 列表补全。

B. registerCleanup() / unregisterCleanup() 背后的 cleanup registry 语义

killAsyncAgent() 会调 unregisterCleanup?.(),但 registry 本身的粒度和生命周期还没完全展开。

C. stale worktree 回收与 resume 的竞态

resumeAgent.ts 里已经有 “bump mtime 防 stale-worktree cleanup 删掉刚恢复的 worktree” 的注释,这说明 worktree 生命周期管理还有另一层后台清理逻辑值得单独分析。


当前结论

AgentTool 这一层已经很明显不是“跑完再说”的粗放实现,而是带着几条非常明确的工程边界:

  1. 状态迁移优先,不要让任务假死
  2. 清理职责按 lifecycle shell 分配,不要前后台双清/漏清
  3. 全局 side registry 统一 finally 清
  4. 慢 cleanup 可以拖,但不该阻塞 terminal state

所以如果要评价它:

它不是绝对无泄漏,而是已经把最危险的“任务卡死、重复通知、前后台双持资源、worktree 误删/漏报”这些坑,尽量拆开并单独设了防线。