[控场AI]
· 9 分钟阅读· 4,603 字

关闭终端就杀死AI编程代理?会话宿主如何保活

关闭终端就杀死AI编程代理?会话宿主如何保活

终端关闭通过内核与shell两跳SIGHUP杀死AI代理,仅tmux类方案保留可交互终端。

文章剖析了关闭终端导致AI编程代理进程死亡的完整链条:内核在pty master关闭时向会话首进程(shell)发送SIGHUP,shell再转发给所有作业。nohup和disown能让进程存活但终端已挂断,代理无法交互;setsid使进程脱离会话但完全失去终端;只有tmux通过独立服务器持有pty,使代理会话与用户窗口解耦,从而支持重新附着。作者据此提炼出代理宿主的六条设计原则:独立会话进程忽略SIGHUP、持有pty master、UI作为socket客户端、输出环形缓冲、协议版本校验、无会话时自退。Windows因ConPTY无法跨进程移动而存在架构缺口,远程SSH+tmux仍是可靠方案。

你把一个长时间的重构任务交给AI编程代理,看着它开始跑测试套件,然后随手关掉了终端窗口——毕竟活已经交给它了。可当你打开新窗口时,进程已经消失,连同所有还没写进磁盘的进度一起蒸发。

关闭终端后代理进程消失

这不是终端应用在"追杀"代理。根据开发者xgabriel在其技术博客中的分析,真正的凶手是内核与shell之间的两跳信号传递:内核告诉shell它的终端挂断了,shell再把SIGHUP转发给它掌管的每一个作业。常见的救命手段——nohup、disown、setsid和tmux——各自切断了不同的环节,但只有一个能让你重新敲键盘进去。

决定生死的四个进程号

要理解代理为什么死,先得认识Unix的两层分组机制。进程组(PGID)代表一个"作业",也就是一条命令行启动的所有进程,Ctrl+C能一次打中全部;会话(SID)则是一组共享控制终端的进程组,创建会话的进程就是会话首进程——在终端窗口里,这个角色是你的shell。

打开一个新窗口跑两个命令就能看明白:

sleep 600 &
ps -o pid,ppid,pgid,sid,tpgid,tty,comm -p "$$,$!"

四个关键列是PID、PGID、SID和TPGID。shell的PID等于它的SID,因为它领导着整个会话;sleep有独立的PGID,因为作业控制把每个作业放进自己的组。两行共享同一个TTY(窗口pty的从端)。TPGID是终端的前台进程组——你坐在提示符前时它是shell的组,代理在前台跑时它就是代理的组。

macOS的ps故意少一列:Apple的工具根本没有sid关键字,最接近的sess打印的是十六进制的内核会话指针而非ID。在Mac上,PGID、TPGID和TTY已经够用了。

SIGHUP 与作业控制:SIGHUP(挂断信号)最初得名于串行线路断开,如今在伪终端场景下由内核在 master 端关闭时触发。它的默认动作是终止进程,但可被捕获或忽略。作业控制则依赖进程组:shell 为每个管道或命令创建独立 PGID,以便向前台组发送 SIGINT(Ctrl+C)、SIGTSTP(Ctrl+Z)等。当会话首进程退出时,POSIX 要求它向控制终端的前台进程组发送 SIGHUP,这是代理可能收到第二个信号的来源。

孤儿进程组:若一个进程组的每个成员都有父进程位于该组之外或已退出,该组成为孤儿组。POSIX 规定:若孤儿组中有成员处于停止状态,系统会向该组所有成员发送 SIGHUP 和 SIGCONT,防止停止的进程永久滞留。这解释了文中 Ctrl+Z 后 disown 仍会死亡的陷阱——停止的代理使组成为孤儿,触发内核信号。

关窗那一刻发生了什么

终端窗口持有伪终端对的master端,shell及其启动的一切连接slave端。关窗就是关闭master——要么应用主动关,要么应用退出。

Linux的pty驱动在master关闭时对slave调用tty_vhangup。这个挂断操作遍历终端会话中的所有进程,向会话首进程(你的shell)发送SIGHUP和SIGCONT,并清除会话中所有指向该终端的进程的控制终端。此后从该tty读取返回0(EOF),写入失败并报EIO。POSIX对任何挂断的终端规定了相同规则。

挂断那一刻,内核只向一个进程发了信号:shell。第二跳是shell自己的策略。Bash在收到SIGHUP后退出,退出前会"把所有运行中或已停止的作业重新发送SIGHUP"。macOS自10.15起的默认登录shell zsh也有一个默认开启的HUP选项,在shell退出时向运行中的作业发送SIGHUP。

前台运行的代理和其他作业没有区别,同样会从shell那里吃到SIGHUP。它甚至可能吃到第二个:POSIX规定控制进程退出时会向终端的正前台组发送SIGHUP,而Linux记住了终端挂断时哪个组在前台。所以即便shell什么都不转发,前台代理也会被信号命中。即便它两次都忽略了,它脚下的终端仍然对每次读取返回EOF。

五种睡法,一扇关上的窗

原作者设计了一个巧妙的对照实验,给每种技术不同的时长以便区分幸存者:

sleep 601 &                     # 普通后台作业
nohup sleep 602 &               # 忽略SIGHUP
sleep 603 & disown              # 从作业表移除
setsid -f sleep 604             # 仅Linux
 tmux new -d -s agent 'sleep 605'

关窗后在新窗口用ps -A -o pid,ppid,pgid,tty,command | grep '[s]leep 60'查看,结果如下:

  • sleep 601是shell的作业,被shell转发的SIGHUP终结。
  • sleep 602吃到同样的SIGHUP但忽略了它——nohup在运行命令前把SIGHUP设为忽略,stdout是终端时还追加输出到nohup.out。
  • sleep 603从未收到信号。disown把作业从shell的作业表里删掉,shell没有东西可转发,而内核只通知了会话首进程。
  • sleep 604活在一个没有控制终端的独立会话里,挂断遍历的是它从未加入的会话。
  • sleep 605属于tmux服务器。

有个值得警惕的坑:如果进程组变成孤儿时其中有成员处于停止状态,会收到SIGHUP加SIGCONT。对代理按Ctrl+Z、disown、再关窗,它照样会死。

活下来不等于还能用

四个sleep活了下来,但对sleep来说故事到此为止。代理不一样——它要画界面、读按键、在执行命令前停下来问你。在nohup或disown之后,它脚下的终端已经挂断:每次读取都是EOF,每次写入都是EIO。在setsid之后,终端压根不存在。进程也许还活着,但没有任何通道能展示它下一个问题或带回你的回答。

tmux是实验中唯一保留了可用终端的方案,因为代理从未把你的窗口当作自己的终端。tmux启动服务器时调用daemon(1, 0),把自己从控制终端分离,并把stdin/stdout/stderr指向/dev/null。每个窗格是服务器自己打开并读取的独立伪终端。你键入的tmux只是客户端,服务器与各客户端通过/tmp下的socket通信。

关窗挂断的是窗口的pty,附着在该窗口的tmux客户端随shell一起倒下。但在socket另一侧,服务器仍持有窗格,代理仍在其中运行。从新窗口执行tmux attach -t agent,新客户端连上同一个窗格。挂断从未触及代理的终端,因为代理会话中没有任何东西附着在你的窗口上。

控制终端与 pty 语义:控制终端是进程会话关联的终端设备,可通过 tcsetpgrp 设置前台进程组。伪终端对由 master 和 slave 组成,slave 模拟传统终端。当 master 关闭,内核挂断 slave:读返回 0(EOF),写返回 EIO,并清除会话中所有进程的控制终端。即使进程忽略 SIGHUP,后续 I/O 也会失败。因此,存活进程若需要交互,必须拥有一个未挂断的终端(如 tmux 窗格的 pty),否则只能作为无终端后台任务运行。

每个代理宿主都该具备的设计

把tmux拆解到最小,任何想让代理活得比窗口久的应用都需要这份清单:

  1. 一个独立的长期进程,处于自己的会话中并忽略SIGHUP,这样垂死的UI或终端带不走它。
  2. 该进程持有每个代理的pty master,每个代理跑在自己的会话里,pty就是它的控制终端。
  3. UI只是客户端,通过仅本用户可打开的socket连接;UI消失时会话继续运行,处于未附着状态。
  4. 无人附着时到达的输出进入缓冲区,返回的客户端可以重放错过的内容并要求程序重绘。
  5. 替换UI二进制文件不影响运行中的宿主,双方通信前校验协议版本。
  6. 宿主在没有会话后自行退出,不留成游离守护进程。

原文作者以自己开发的Hermes IDE为例说明这套设计的落地:hermes-pty-host是一个小型后台进程,"拥有PTY及其程序";应用在第一个会话需要时启动它,从按版本区分的副本启动,因此更新"不会触碰运行中的宿主";应用通过带token的用户专属Unix socket附着,把协议版本不符的宿主视为不可用,返回时"重新附着到宿主仍持有的会话,重放错过的输出并促使程序重绘"。其main.rs在"自己的会话:应用的终端或死亡绝不能带走宿主"的注释下调用了setsid(),server.rs在启动时忽略SIGHUP,默认每个会话保留2 MiB输出环形缓冲区。pty来自wezterm的portable-pty,它在每次spawn的程序exec前调用setsid()并可将pty设为控制终端。

Windows是明显的缺口。ConPTY"无法在进程间移动",所以在Windows上宿主报告自身不受支持,会话留在应用内。在Windows上、或代理需要比笔记本活得更久时,远程机器上通过SSH跑tmux仍是正解。

daemon(1,0) 与 setsid:daemon(nochdir, noclose) 是 libc 函数,内部调用 fork、setsid,并可选重定向标准流到 /dev/null。setsid() 创建新会话,使调用进程成为会话首进程且无控制终端。tmux 服务器借此脱离原终端,成为独立会话首领,从而不受窗口关闭影响。代理宿主同样需调用 setsid() 并忽略 SIGHUP,以抵御父进程退出时的信号。

Unix 域套接字与权限:Unix 域套接字(AF_UNIX)用于同一主机进程间通信,支持流式和数据报。文件系统路径上的套接字受文件权限保护,宿主可创建仅用户可读写的套接字(如 chmod 0600),防止其他用户连接。客户端通过 connect() 附着,服务器用 accept() 接受连接,实现 UI 与宿主的解耦。

编辑视角

这篇文章的价值在于把一个日常痛点还原成清晰的系统调用链条:内核→会话首进程→作业表,三跳各有各的切断方式。它同时戳破了一个常见误解——进程存活不等于可用,没有可用终端的代理只是个占着内存的僵尸。

对构建AI编程工具的团队来说,这份宿主设计清单几乎可以直接当需求文档用。值得补充的是,环形缓冲区大小、协议版本策略、无会话退出超时这三个参数,才是决定体验优劣的真正旋钮,而不是"用不用tmux"这种表面选择。

#SIGHUP#AI编程代理#会话宿主#tmux#终端进程保活#setsid#nohup#伪终端pty
分享:

相关推荐