上一篇说工具里最能闯祸的是能执行任意命令的 Bash。这篇讲它给我挖的一个大坑------一个让我排查方向从头错到尾的坑。
现象:agent 卡住,60 秒后还杀不掉
agent 在干一个活,中途要起一个本地服务(类似 python proxy.py 这种,起来之后一直跑、给后面的步骤用)。它在命令后加了个 & 想让它后台跑。
然后,整个 agent 卡住了。
我给 Bash 工具设了个超时:一条命令 60 秒还不结束,就判定出问题、杀掉。可这次,60 秒到了,杀不掉------那进程赖在那儿,agent 彻底卡死在那一步。
💥 我一头扎进了"怎么杀进程"
我盯着"杀不掉"三个字,一头扎进了进程管理的细节:研究 kill 为什么没生效、怎么处理进程组、怎么把它和子进程一锅端、加锁防重复杀......折腾大半天。
然后我停下来,问了自己一个早该问的问题:这个进程,本来就不该被杀啊。
agent 起这服务,是要让它一直跑着 给后面用的。我却在吭哧吭哧研究"怎么杀得更干净"------我在认认真真地解决一个根本不该发生的问题。
真正的根因:我没区分"命令"和"服务"
退一步看,根子特别清楚:我的 Bash 工具,把所有命令都当成"会结束的命令"来对待了。
可命令分两种:
- 会跑完的 :
ls、npm install、跑个测试,执行完就退出。等它、给它设超时,天经地义。 - 压根不打算结束的:起服务、开守护进程,它的"正常状态"就是一直运行。给它设超时、到点杀,完全南辕北辙。
我把第二种当第一种处理,于是"等结束 → 等不到 → 超时 → 杀"这条链,从第一步就错了。
修法 :让 Bash 工具不再"一刀切等到死再杀",而是认清一条命令其实有三条路径可走。
一条命令的三条路径
我把执行逻辑改成了下面这样(伪代码,贴着真实分支写的):
ts
// 路径 1:你明说"扔后台"------立刻登记、立刻返回,根本不等
if (args.run_in_background === true) {
register(task)
return backgrounded({ task_id, output_path }) // 给个 id + 输出文件路径就走
}
// 路径 2 / 3:同时等"三件事",谁先到算谁
const who = await Promise.race([
whenClosed, // 命令自己跑完了
timeoutAfter(120_000),// 默认 2 分钟到点(不是 60 秒了)
whenAborted, // 用户点了"停"
])
if (who === "closed") return result // 路径 2:正常跑完,返回输出
if (who === "aborted") { treeKill(pid); ... } // 用户停:整棵进程树一起杀
// 路径 3(关键!):到点超时------不杀,而是"提拔"成后台任务
if (who === "timeout") {
register(task) // 留在登记表里,继续让它跑
return backgrounded({ task_id, output_path, reason: "timeout" })
}
最妙的是路径 3 ------它才是开头那个坑的真正解药。一条命令到点了还没结束,我不再一刀切杀掉 ,而是默认它"要么是个服务、要么只是跑得慢",于是自动把它转成后台任务 :登记下来、让它继续跑,给 agent 返回一个 task_id + 一个输出文件路径。agent 之后想看进展就 Read(output_path),想停就 TaskStop(task_id)。
这样,无论 agent 有没有显式说"扔后台",长服务都不会被误杀 ,而 agent 也不会傻等------回过头看开头那个 proxy.py 卡死,根子就是当年只有"等→杀"两条路、缺了路径 3 这条。
后台进程不是"扔了就不管"
"扔后台"不等于"撒手不管"。我给每个后台任务建了个登记表,一条记录长这样(精简版,真实字段就这些):
ts
interface BgTask {
bgId: string // 会话内唯一 id,形如 bg_<nanoid>
sessionId: string // 属于哪个会话(删会话时按它收割)
command: string // 实际跑的命令
status: "running" | "completed" | "failed" | "killed"
pid: number | undefined // 进程号 ------ 停它的时候要用
stdoutPath: string // 输出落到这个文件(不塞内存,见第 5 篇大对象那条)
stderrPath: string
stdoutReadOffset: number // 已经读到输出的第几个字节(增量推送用)
}
围着这张表,"管好它"是三件具体的事:
- 登记 :
register(task)把它塞进一个Map<bgId, BgTask>,还顺手校验sessionId对不对(防串台)。找它就get(bgId)/list()。 - 看输出 :服务的 stdout/stderr 直接落文件 (
stdoutPath),不进内存。一个 1 秒轮询器 盯着这些文件,靠stdoutReadOffset记住"上次读到哪了",只把新增的那一段 推给前端(增量,不重发整篇)。agent 想看全的,Read(stdoutPath)就行。 - 能停 :
pid在手,停它用的是treeKill(pid, "SIGKILL")------注意是 tree-kill,把它连同拉起的子进程整棵树 一起杀,而不是只杀父进程留一地孤儿。会话关闭时,registry.shutdown()把表里所有还running的挨个 treeKill 再清空。
少一样,它就要么变成找不着的野进程,要么变成杀不掉的僵尸。
🤔 这个后台服务,该活多久
这里有个真正需要拍板的设计问题:一个后台服务,该跟着"这一轮对话"死,还是独立活下去?
我的取舍是:后台服务故意做成"跨轮独立活"------agent 这一轮结束了,它起的服务还在跑,下一轮还能用。这才符合"起个服务给后面用"的初衷。
但"独立活"不能变成"永远没人管"。所以我给它划了两条边界:
- 整个会话被删了 → 它得跟着停(别留个没主的野进程占着端口);
- 程序整个退出了 → 所有后台进程都得有序收掉(靠进程退出兜底)。
"故意不跟着停"是一种合理设计,但你必须把它写明白、还得测试钉住------哪天重构的人不知道这是故意的,手一抖给改成"跟着停",用户的长服务就莫名其妙断了。有意为之和漏掉,必须区分得清清楚楚。
这其实又是第十篇"级联取消"那套思路的另一面:大多数"会等会跑"的东西要跟着停,但少数故意不停的,要明确标注。
💥 用户点了一次"取消",之后所有命令都静默失败
再讲个特别阴的坑------而且它正好踩在上面那个 race 上。
现象 :用户在某一轮点了"取消"。然后------这个会话里后续所有 Bash 命令,全都静默返回 exitCode: -1、空输出 ,连 echo hello、pwd 这种必然瞬间完成的也一样。没报错、没日志、没提示,就默默返回空,特别像"命令没输出",让人反复重试还摸不着头脑。重启进程才能恢复。
根因 :还记得上面那个 Promise.race([跑完, 超时, 用户停]) 吗?那个"用户停"靠的是一个取消信号(AbortSignal)。用户点取消时它被置成 aborted------本该这一轮用完就丢。可它残留、还被复用到了后续命令上 :于是后面每条命令一进来,race 一看"信号已经是 aborted 了",二话不说直接走 abort 分支返回 -1,命令根本没机会跑。
最损的是它静默 :我当时追了好久,一度以为进程崩了,ps、echo 全试一遍,全 -1。最后(说出来好笑)是往 bash 代码里随手加了行 console.log、触发了热重启,Bash 才活过来------这才反应过来是个残留状态,重启即愈。
修法 :两条。一是每条命令用的取消信号必须是这一轮自己的、用完即弃 ,绝不复用上一轮那个可能已 abort 的(正是第 10 篇说的:取消信号的生命周期得划清楚)。二是失败别再静默------给 agent 一个看得见的错误(错误码 + 建议),而不是默默返回 -1 让它对着空气重试。
教训 :一个"静默失败"比"报错失败"危险得多。 报错你至少知道哪儿炸了;静默返回个空,你会朝着完全错误的方向查上半天。
这件事真正的教训,跟进程无关
修复本身不难。难的是我花了大半天才意识到自己在解决错误的问题。
我后来把这次经历总结成一条方法,贴在显眼处:
排查问题,先问"它当时想干嘛",再问"代码哪儿错了"。
如果我一开始就先想清楚"agent 起这命令是要长期用的",而不是被"杀不掉"这个表面现象牵着走,我根本不会去碰那堆进程管理代码。一个缺陷可能真实存在,但你未必该走到那个分支。
这也是为什么,我现在让 AI 查 bug,会特意加一句:先别急着找代码哪儿错,先告诉我这操作本来想达成什么。 你给它的问题怎么定性,就框死了它往哪儿使劲------这点,后面讲"和 AI 协作"那几篇还会反复提。
小结一下
- Bash 最大的坑,是把长跑服务 当成会结束的命令 ,于是"等待---超时---杀"整条链从头就错;修法是区分两类,后台那类显式登记、别等也别杀;
- "扔后台"不是撒手不管:要能登记、看输出、干净地停;
- 🤔 后台服务故意做成跨轮独立活,但删会话/退程序时要收掉,且这个"故意"必须标注 + 测试钉死;
- 💥 用户一取消,残留的 abort 信号让后续命令全静默返 -1------取消信号要用完即弃,且失败别静默;
- 最大的教训:排查先问 intent,别被表面现象带着精修一个不该走的分支。
既然聊到"让 agent 自己跑命令",那道绕不开的关就来了:有些命令是危险的,总不能让它想跑就跑。下一篇:让 AI 自己跑命令,但危险动作得先问我一声。