killShellTasksForAgent 机制

这页回答什么

runAgent() finally 里说会 kill 该 agent 启动的 background shell tasks,防止 shell loop 在 agent 结束后继续存活。这个 kill 到底只是改 task state,还是会真碰到底层 shell command / process?

关键文件:

  • src/tools/AgentTool/runAgent.ts
  • src/tasks/LocalShellTask/killShellTasks.ts

一句话结论

killShellTasksForAgent() 不是只改个 UI 状态,它会顺着 LocalShellTask 真的打到 shell task 持有的运行对象:

  • 找出 task.agentId === 当前 agentId
  • status === 'running' 的 local shell task
  • 对每个任务调用 killTask(taskId, setAppState)
  • killTask() 内部会直接:
    • task.shellCommand?.kill()
    • task.shellCommand?.cleanup()
    • task.unregisterCleanup?.()
    • cleanupTimeoutId
    • 再把 task 标成 killed

所以这条不是“软标记”,而是:

先尝试真杀底层 shell command,再同步 task state,并顺手清理 agent 已经不会再消费的通知队列。


1. runAgent() finally 为什么要调它

runAgent.ts 的注释已经很直白:

如果不清,agent 启动的 run_in_background shell loop 会在 agent 结束后继续活着,等主 session 退出后甚至可能变成 PPID=1 孤儿/僵尸风格残留。

这说明 Claude Code 识别到一个明确风险:

  • agent 生命周期结束
  • 不代表它拉起的 background bash task 会自动结束
  • 如果不显式杀掉,就可能出现 orphan process

所以 killShellTasksForAgent(agentId, ...) 是 agent finally 的必备补刀。


2. 选择范围:只杀“属于这个 agent 的 running shell task”

killShellTasksForAgent() 会扫:

const tasks = getAppState().tasks ?? {}

然后过滤:

  • isLocalShellTask(task)
  • task.agentId === agentId
  • task.status === 'running'

这说明它的 kill 范围不是全局 bash 任务,而是:

agent-scoped local shell tasks

因此它不会误杀:

  • 其他 agent 的 shell task
  • 已经 terminal 的 task
  • 非 LocalShellTask 的任务类型

3. killTask() 不是只改状态,确实碰 shellCommand

killTask(taskId, setAppState) 里的关键顺序:

task.shellCommand?.kill()
task.shellCommand?.cleanup()

然后才:

  • task.unregisterCleanup?.()
  • clearTimeout(task.cleanupTimeoutId)
  • status: 'killed'
  • shellCommand: null
  • unregisterCleanup: undefined
  • cleanupTimeoutId: undefined
  • endTime: Date.now()

所以它明显有两层动作:

第一层,真实执行体清理

  • shellCommand.kill()
  • shellCommand.cleanup()

第二层,task state / 引用清理

  • status 改 killed
  • 释放 command / cleanup handle / timeout handle

这说明它不是单纯给 UI 看,而是真往运行体打过去。


4. 这条 kill 仍然是 best-effort,不是硬保证

虽然它会调用:

  • shellCommand.kill()
  • shellCommand.cleanup()

但这里仍然包在:

try { ... } catch (error) { logError(error) }

也就是说如果底层 kill / cleanup 失败:

  • 错误会记日志
  • task state 仍然会继续迁到 killed

所以它的优先级仍然是:

先把逻辑任务视为结束,再 best-effort 回收底层进程。

这和前面 AgentTool / LocalAgentTask 的整体设计风格完全一致。


5. 为什么还要 unregisterCleanupcleanupTimeoutId

这说明 LocalShellTask 本身除了 shellCommand 外,还挂着额外的 cleanup 机制:

  • unregisterCleanup,应该来自全局 cleanup registry
  • cleanupTimeoutId,说明 shell task 可能有延迟清理/超时清理逻辑

kill 时把它们一起拆掉,目的是:

  • 防止 kill 后还有迟到 cleanup 再跑一遍
  • 防止 registry 继续保留旧 task 的 cleanup closure
  • 避免 double-cleanup / stale timeout

这也是很典型的“清引用 + 清 future cleanup trigger”。


6. 还会 evictTaskOutput(taskId)

killTask() 最后还会:

void evictTaskOutput(taskId)

说明 shell task 对应的输出文件 / disk output 也会被驱逐。

这让 kill 不只停留在:

  • 进程
  • task state

还延伸到了:

  • task output 生命周期

7. 一个容易忽略的点:还会清通知队列

killShellTasksForAgent() 最后还有:

dequeueAllMatching(cmd => cmd.agentId === agentId)

而注释说得非常关键:

  • 这个 agent 的 query loop 已经退出
  • 它不会再 drain 自己的 queued notifications
  • 已经在队列里的通知要直接 purge
  • 之后如果还有迟到通知落进来,也只是“无消费者”,不会再被 dead agentId 匹配

这说明 shell task 清理不只是 process cleanup,而是连:

agent-scoped queued commands / notifications

也一起考虑到了。

这是很好的细节,不然会出现:

  • agent 死了
  • 但队列里还堆着给它的消息
  • 永远没人消费

8. 所以它解决的是哪一类问题

killShellTasksForAgent() 主要防的是三类残留:

A. OS / shell 执行体残留

  • background shell loop 继续跑
  • 最终变 orphan

B. task object 残留

  • shellCommand 引用
  • cleanup closure
  • timeout handle

C. queued notification 残留

  • agent 已死
  • 但队列里还有指向它的命令

这比“kill bash task”四个字宽得多。


9. 当前能下的准确判断

如果问:

这条机制是真的会杀底层 shell 吗?

答案是:

  • 会尝试真杀,因为直接调用了 shellCommand.kill()shellCommand.cleanup()
  • 但整体仍是 best-effort,因为异常被吞日志,task state 仍然照样迁移到 killed

所以更准确的表述是:

killShellTasksForAgent() 是一个“先打运行体,再清 task/queue 引用”的 best-effort agent-scoped orphan prevention 机制。


10. 顺手得到的更大结论

这页也反过来加强了前面一个总判断:

runAgent() finally 不是只清自己函数内部的局部对象,而是在回收“该 agent fan-out 出去的附属执行体”。

也就是说 agent 结束时,Claude Code 想收的不是单一 iterator,而是:

  • agent 本体
  • agent 注册的 hooks / cache
  • agent fan-out 的 shell tasks
  • agent fan-out 的 queue items

这就是一个真正的 agent-scope cleanup 边界。


下一步最值钱的继续点

  1. LocalShellTask 自己的创建与 shellCommand 封装,确认 kill() 具体是 SIGTERM / 子进程树 kill / 其他抽象
  2. 继续追 cleanupRegistry / registerCleanup,搞清 unregisterCleanup 在 shell task 里到底解绑什么
  3. 如果能找到 MonitorMcpTask 源码,再对照做一页 monitor task cleanup

当前结论

killShellTasksForAgent() 已经可以确认不是纯 UI 逻辑,而是真正触达底层 shell 任务对象并尝试停止它们,同时清理 task state、output 和 dead-agent 队列消息。

因此前面 runAgent() finally 里对它的调用,可以理解成:

agent 退出时的“孤儿 shell 子任务收尸器”。