SSH 取消后,远程进程清理还能生效吗?
SSH 取消后的远程进程清理需要进程组、持久化状态、子进程测试,以及面对断开连接的诚实方案。

一次被取消的智能体运行,并不等于远程命令已经停止。本地进程可以正常退出,而 SSH 传输仍然保持连接;传输可能消失,而远程 shell 继续运行;shell 也可能退出,而它的子进程在另一个进程组中继续运行。如果把这些事件都压缩成一个名为“已取消”的状态,迟早会出现这样的情况:智能体报告任务已停止,但数据库迁移、软件包安装、测试工作进程或部署辅助程序仍在运行。
解决办法不是单靠一个巧妙的信号陷阱。你需要一份取消契约,明确标识远程运行,围绕其后代进程创建可终止的边界,保留足够的输出来诊断中断,并为控制端消失的情况准备远程侧方案。在允许智能体运行有副作用的命令之前,先构建并测试这份契约。
SSH 取消操作经过四个独立环节
取消请求必须跨过四个边界:智能体决定停止;本地监管程序停止或向 SSH 客户端发送信号;SSH 协议传递通道事件或信号;远程主机根据事件采取行动。每个环节都可能独立失败。
RFC 4254 区分了这些概念。它定义了通道关闭消息,也单独定义了可携带 TERM、INT 和 HUP 等名称的 signal 通道请求。通道关闭是传输事件,并不表示“向每个远程后代进程发送 SIGTERM”。RFC 还指出,在可能的情况下,关闭前发送的数据应当被交付。当笔记本进入睡眠、网络路由中断或本地进程被强制结束时,“在可能的情况下”会变得很复杂。
这个区别能揭示一个常见的错误设计:
- 智能体启动
ssh host long-command。 - 用户按下取消。
- 智能体运行器终止本地子进程。
- 界面将任务标记为已取消。
long-command或其某个子进程继续在远程主机上运行。
第五步不是边缘情况。只要服务器没有理由终止命令,或者命令在连接消失前脱离了会话,它通常就是默认结果。
一份有用的取消契约应明确说明本地端会尝试什么,以及远程端负责什么:
- 启动器使用随机运行 ID 创建一个可识别的远程运行。
- 远程包装器让工作负载运行在独立的进程组或会话中。
- 正常取消时向该进程组发送
TERM,并记录结果。 - 只有经过明确规定的宽限期后,包装器才升级为
KILL。 - 如果控制端始终没有回来,远程期限或租约会结束任务。
- 输出和最终状态不会依赖 SSH 流才能保留。
在得到以下两种结果之一之前,不要把任务称为已取消:已确认的远程最终记录,或明确的“状态未知”结果。连接丢失后假装确定,会让故障响应变慢,因为所有人都从一个错误前提开始调查。
只有远程 PID 不足以清理子进程
只有在 shell 从不派生进程、不启动管道、不运行后台进程,也不调用会创建辅助进程的工具时,终止远程 shell PID 才安全。这类命令很少。
看一个普通的远程命令:
build-assets | tee build.log &
wait
shell 只有一个 PID,但管道中有多个进程。shell 退出后,tee 可能仍在写日志。编译器可能启动工作进程,软件包管理器可能把工作交给服务。如果执行 kill -TERM "$shell_pid",你只是终止了更大进程组中的一个成员,对其他进程几乎一无所知。
对于短期远程运行,进程组能提供正确的取消单位。在 Linux 中,每个进程属于一个进程组,每个进程组属于一个会话。终端生成的信号会发送给前台进程组,所以终端行为看起来可能比实际更神秘。Linux 的 setpgid(2) 文档也明确说明,除非发生变化,子进程会继承父进程的进程组。
对于由智能体控制的命令,应为工作负载创建新会话。会话领导者通常拥有与 PGID 和 SID 相同的 PID。这样,kill 中的负 PID 就可以指向整个进程组:
kill -TERM -- -"$pgid"
开头的负号决定了你终止的是一个进程还是它的进程组。-- 同样重要,它可以避免格式错误的值被解析成选项。
不要想当然地认为工作负载 PID 就是进程组 ID。在启动时检查它。shell 包装器、服务管理器或调用 setpgid 的程序都可能改变进程树。测试工具中至少应保留这条检查命令:
ps -o pid=,ppid=,pgid=,sid=,stat=,etime=,command= -p "$pid"
典型输出如下:
24182 24177 24182 24182 Ss 00:03 bash ./worker.sh /tmp/agent-runs/6c4...
这里 PID、PGID 和 SID 都相同。这说明 kill -TERM -- -24182 指向了预期边界。如果 PGID 与运行记录不一致,应让启动失败,而不是猜测。
进程组仍有边界。子进程可以调用 setsid,容器运行时可以把进程移动到其他位置,工作负载也可能请求服务管理器在进程组之外运行任务。这些行为有时是合理的,但也意味着你的取消保证在交接处结束了。把脱离管理的工作视为独立的任务类型,为它设置自己的身份、停止操作和审计记录。
取消包装器需要真正的清理路径
远程 shell 包装器应负责保存工作负载 PID,捕获预期的终止信号,定位工作负载进程组,短暂等待,并写入最终记录。不要使用 pkill command-name、扫描松散的进程列表,或终止某个账户下的所有进程。这些捷径在某一天之前看似有效,但当两个智能体运行共享同一用户、主机名发生变化,或命令名碰巧匹配别人的工作时,就会造成问题。
下面这个面向 Linux 的测试夹具刻意保持简单。它创建受保护的运行目录,让一个工作负载在新会话中启动,将输出写入文件,并在包装器收到 TERM、INT 或 HUP 时终止工作负载的进程组。
#!/usr/bin/env bash
set -Eeuo pipefail
run_id=${1:?run ID required}
shift
run_dir="${HOME}/.agent-runs/${run_id}"
umask 077
mkdir -p "$run_dir"
child_pid=""
child_pgid=""
finished=0
write_status() {
local state=$1
local code=${2:-}
local tmp="$run_dir/status.tmp"
printf '{"run_id":"%s","state":"%s","exit_code":"%s"}\n' \
"$run_id" "$state" "$code" >"$tmp"
mv "$tmp" "$run_dir/status.json"
}
stop_group() {
if [[ -z ${child_pgid:-} ]]; then
return
fi
kill -TERM -- "-$child_pgid" 2>/dev/null || true
for _ in 1 2 3 4 5; do
if ! kill -0 -- "-$child_pgid" 2>/dev/null; then
return
fi
sleep 1
done
kill -KILL -- "-$child_pgid" 2>/dev/null || true
}
cancel() {
local signal=$1
trap - TERM INT HUP
write_status "cancelling:$signal"
stop_group
wait "$child_pid" 2>/dev/null || true
write_status "cancelled:$signal"
finished=1
exit 143
}
trap 'cancel TERM' TERM
trap 'cancel INT' INT
trap 'cancel HUP' HUP
write_status "starting"
setsid "$@" >"$run_dir/stdout.log" 2>"$run_dir/stderr.log" &
child_pid=$!
child_pgid=$(ps -o pgid= -p "$child_pid" | tr -d ' ')
if [[ "$child_pgid" != "$child_pid" ]]; then
printf 'unexpected PGID for %s: %s\n' "$child_pid" "$child_pgid" \
>"$run_dir/stderr.log"
kill -TERM "$child_pid" 2>/dev/null || true
write_status "launch_failed"
exit 70
fi
printf '%s\n' "$child_pid" >"$run_dir/pid"
printf '%s\n' "$child_pgid" >"$run_dir/pgid"
write_status "running"
set +e
wait "$child_pid"
code=$?
set -e
if [[ $finished -eq 0 ]]; then
write_status "finished" "$code"
fi
exit "$code"
这个包装器能避免一种具体故障:取消信号到达包装器,但包装器只终止自己,让工作负载失去管理而继续运行。它并不承诺终止那些有意脱离管理的后代进程,也不应该做出这样的承诺。
Linux 的 setsid(2) 手册说明,setsid() 会创建新会话,并让调用者成为新进程组的领导者,最初不拥有控制终端。util-linux 的 setsid 命令会在新会话中运行程序,并在需要时派生进程。因此,它是命令运行的实用边界,而不是神奇的清理开关。
让包装器保持精简。它应该负责启动、记录、停止和报告,不要把业务逻辑埋进去。工作负载仍应自行处理事务、临时文件清理和幂等规则。
传输正常断开不等于清理保证
SSH 用户经常从终端测试中得出过多结论。他们使用 PTY 运行命令,关闭终端,看到进程在 SIGHUP 后退出,于是认为断开清理有效。随后智能体使用没有 PTY 的非交互式 SSH 通道,行为就变了。
PTY 提供终端语义。终端挂断可能导致发送 SIGHUP,但这取决于控制终端和前台进程组等条件。Linux 手册将 SIGHUP 描述为控制终端挂断或控制进程死亡。这个描述并没有说每次 SSH 断开都会向通过 SSH 启动的每个进程发送信号。
对于智能体,非交互式 SSH 通常是更好的默认选择,因为它能提供更干净的输出,减少 shell 启动脚本带来的意外。它也能避免对终端行为产生偶然依赖。只有当远程程序确实需要终端时才使用 PTY,例如某些拒绝在没有终端的情况下运行的旧安装程序。此时应记录 PTY 是命令行为的一部分,并单独测试。
有三种断开情况值得明确区分:
客户端发送了明确的取消请求
本地监管程序仍有活动连接,可以发送协议信号,也可以打开一个独立且经过认证的控制命令,向记录下来的 PGID 发送信号。这是最理想的情况。远程包装器收到 TERM,完成清理,并写入 cancelled:TERM。
除非你已经测试过实际发布的客户端库和调用方式,否则不要认为 SSH 客户端收到本地 SIGINT 就一定会完成这件事。终端客户端、内置 SSH 库和 MCP 工具可能以不同方式映射本地取消。有的会关闭套接字,有的会终止本地进程,有的可以发送 SSH signal 请求。这些都是对用户称为“取消”的接口的不同实现。
本地客户端崩溃或网络丢失
远程命令可能继续运行。除非你的协议告诉服务器如何处理,否则服务器无法区分临时路由问题和用户希望任务继续运行的情况。远程租约是诚实的解决方案。
启动时,将 deadline_epoch 写入远程运行目录。本地监管程序在任务仍获授权时续租。远程看门狗检查该值,过期后调用同一条进程组清理路径。租约时长应符合操作特点。五分钟租约可能适合 shell 命令,但对于存在长时间正常静默阶段的构建任务来说可能过于激进。
远程主机故障或重启
你可能同时丢失进程和最终状态。不要仅仅因为 SSH 连接结束,就报告“已取消”或“已完成”。在后续协调过程读取主机日志、部署状态、锁记录或应用特定结果之前,将运行标记为未知。
难点不在于发出一个状态词,而在于拒绝发出系统无法支持的状态词。
部分输出只能证明你观察到过,并不能证明任务完成
数据流回答的是“客户端到目前为止收到了哪些字节”,并不回答“远程命令留下了什么状态”。当远程程序在刷新最后一个文件前打印 done,或命令已经完成但网络在客户端收到 SSH 退出状态前中断时,这个错误就会出现。
保留两类记录:
stdout.log和stderr.log保存工作负载写出的诊断输出。status.json是一个小型最终记录,由包装器在观察到退出或处理取消后以原子方式写入。
包装器中的 mv 很重要。先在同一目录中写入临时状态文件,再将它重命名到目标位置。读取者看到的只能是之前完整的文件或新的完整文件,不会读到半个 JSON 文档后自行猜测结果。
输出本身也需要规则。当 stdout 不是终端而是文件时,命令可能大量缓冲输出。如果进度信息重要,应让工作负载向 stderr 输出明确的按行状态,或使用应用级进度文件。不要为了处理缓冲而为每个命令分配 PTY,因为这会改变行为,并可能把 stdout 与 stderr 合并,使审计和故障分析更困难。
在智能体界面或日志中,应区别处理以下情况:
| 远程记录 | 数据流状态 | 含义 |
|---|---|---|
finished,存在退出码 | 完整 | 包装器观察到正常完成。 |
cancelled:TERM | 可能突然结束 | 包装器开始取消并停止了进程组。 |
只有 cancelling:TERM | 已断开 | 清理已经开始,但没有观察到最终记录。需要协调。 |
只有 running,租约有效 | 已断开 | 任务可能仍在运行。不要盲目重试。 |
| 没有可用记录 | 已断开 | 状态未知。重新启动前检查副作用。 |
不要将访问令牌、未脱敏的配置转储或凭据写入这些日志。通过 SSH 注入凭据可以让私钥远离智能体,但远程命令仍可能打印它从自身环境或配置中读取的秘密。输出保留策略是命令设计的一部分,不是事后补丁。
测试进程树,而不是测试一个安静的 shell
trap 'exit' TERM; sleep 600 是很弱的取消测试。它只能证明一个前台 shell 可以接收一个信号,无法测试子进程、进程组、延迟清理、输出持久化,或远程 shell 在错误时机消失的情况。
使用会创建可见进程树并记录每个信号的工作负载。在一台临时 Linux 主机上将下面内容保存为 worker.sh:
#!/usr/bin/env bash
set -Eeuo pipefail
run_dir=${1:?run directory required}
note() {
printf '%s pid=%s pgid=%s %s\n' \
"$(date +%s)" "$$" "$(ps -o pgid= -p $$ | tr -d ' ')" "$1" \
>>"$run_dir/worker.log"
}
trap 'note TERM; exit 143' TERM
trap 'note INT; exit 130' INT
trap 'note HUP; exit 129' HUP
(
trap 'note grandchild_TERM; exit 143' TERM
trap 'note grandchild_HUP; exit 129' HUP
while :; do
note grandchild_tick
sleep 1
done
) &
grandchild=$!
note "started grandchild=$grandchild"
while :; do
note parent_tick
sleep 1
done
通过包装器使用随机运行 ID 启动它。在另一个 SSH 会话中检查进程树和运行目录:
run_id=cancel-test-$(date +%s)
ssh host.example './remote-wrapper.sh '"$run_id"' ./worker.sh \"$HOME/.agent-runs/'"$run_id"'\"'
实际启动器中的引号会有所不同,这没有问题。不能改变的是测试证据:你需要远程运行 ID、包装器 PID、工作负载 PGID、日志位置和最终状态。
然后逐一测试这些故障路径:
- 向包装器 PID 发送
TERM。确认父进程和孙进程都记录了终止,并且ps找不到记录 PGID 中的进程。 - 直接向工作负载 PID 发送
TERM。确认不能假定其子进程行为安全。这个测试说明了包装器为什么要针对进程组。 - 终止本地 SSH 客户端,但不发送远程信号。确认工作负载会一直运行到远程租约过期。如果它立即停止,应记录原因,例如 PTY 挂断行为,而不要把这个结果视为普遍规律。
- 在包装器写入
cancelling:TERM后、写入最终记录前断开连接。确认协调过程能够区分观察不完整和一个新启动的运行任务。 - 在同一账户下启动两个任务,取消其中一个,并证明另一个仍然存活。这能发现危险的广泛
pkill和账户级清理逻辑。
测试时使用 ps 和 pgrep -a -g "$pgid",并在宽限期后再次检查。先检查进程表,再检查远程状态文件和日志。如果状态记录显示“已取消”,但工作进程仍然存活,这是监管程序的错误,而不是无害的报告不一致。
父进程死亡信号只在受控工作进程中有帮助
Linux 提供 PR_SET_PDEATHSIG,允许进程请求内核在创建它的线程终止时发送信号。当你拥有一个小型原生辅助程序,它会启动一个直接子进程,并希望辅助程序死亡时子进程也停止,这项功能很有用。通常情况下,该设置会在 execve 后保留,但手册记录了重要例外,包括凭据变化。
它本身无法解决远程智能体清理问题。
首先,SSH 服务器进程未必是你真正关心的父进程。其次,该信号只适用于设置它的进程,不会自动覆盖所有后代。再次,远程命令一旦派生、二次派生,或将工作交给其他服务,就会脱离这种关系。最后,它是 Linux 特有的功能,如果主机环境混合使用不同 Unix 系统,这一点很重要。
只有在进程树由你控制时,才把它作为补充机制。例如,小型 Linux 工作进程可以在执行一个受控子进程前设置 PR_SET_PDEATHSIG,同时外层包装器继续负责进程组和租约。这样就有了两个作用范围不同的故障检测器,但这并不意味着可以跳过进程组边界或远程最终记录。
对工作负载中的 nohup、disown 和 setsid 也应采取同样的态度。有人明确希望任务在终端退出后继续运行时,它们很有用;但它们与“取消智能体运行就停止任务”的承诺不兼容。应在启动时明确选择。
第二条控制通道通常比终止第一条通道更干净
智能体取消正在进行的 SSH 调用时,自身的本地执行上下文可能已经开始退出。依赖即将消失的进程发送最后一个协议信号,会引入竞争条件。应让独立的监管进程负责取消和协调。
一种可行的设计是:
- 监管程序生成密码学安全的随机运行 ID,并调用远程包装器。
- 包装器在以运行 ID 命名的目录中记录 PID、PGID、启动时间和状态。
- 监管程序在开始消费输出前,记录运行 ID 和远程主机。
- 取消时,监管程序打开新的控制操作,读取运行记录,核验预期所有权和运行时长,然后向记录的 PGID 发送信号。
- 监管程序轮询最终状态记录,直到看到终止状态或达到报告期限。
控制操作在发送任何信号前必须验证运行记录。至少要检查记录目录属于预期用户、PID 仍然存在、记录的 PGID 与 ps 一致,以及启动时间与启动的进程相符。Linux 可能复用 PID。过期 PID 文件加上无条件的 kill,可能让失败的清理脚本在数周后终止无关任务。
不要把清理命令放在类似“终止我上一个任务的进程”这样的通用智能体指令后面。智能体应接收不透明的运行句柄,监管程序再把句柄转换成范围严格受限的远程操作。这样也更便于审计:审核者看到的是运行 6c4... 请求在一台主机上取消 PGID 24182,而不是智能体拼出了一条任意终止命令。
对于自主编程智能体,Sallyport 可以在不向智能体暴露 SSH 密钥的情况下执行 SSH 操作。这样能将凭据保管与取消契约分开,但取消契约仍需要运行句柄、进程组检查、远程租约和最终记录。
正确的重试决定取决于副作用
只读取文件的命令通常可以在未知断开后重试。但创建用户、执行迁移、轮换证书或启动部署的命令不能这样处理。SSH 层无法告诉你某项操作是否已经安全地重复执行。
为会产生副作用的远程命令提供由运行 ID 派生的幂等令牌。远程程序应将令牌与操作结果一起保存,再次看到相同令牌时返回已有结果。如果做不到,就添加预检查询,确认请求的变更是否已经发生。
不要用清理代替幂等性。即使 TERM 设计得很完善,也可能在远程 API 接受请求之后、命令打印响应之前到达。即使 KILL 及时执行,也可能发生在数据库事务提交之后。进程清理只能回答工作进程是否仍在执行,不能撤销外部副作用。
控制面应保留三种结果:已完成、已确认清理的已取消,以及未知。未知令人不舒服,但它能告诉下一位操作人员应该怎么做:在发出新操作前检查远程状态。这远胜于依靠侥幸的绿色重试按钮。
当你能够在每个边界中断取消操作,并解释残留的进程树、磁盘上的输出、最终记录和重试决定时,取消功能才算准备就绪。如果在临时主机上都做不到这些,就不要在凌晨两点信任它处理生产主机。
常见问题
AI 智能体取消任务后,如何停止远程 SSH 命令?
把取消操作当作独立的控制操作,而不是依赖本地 SSH 客户端退出。向记录下来的远程进程组发送信号,等待一段有上限的清理时间,然后从持久化记录中核验任务的最终状态。如果控制路径已经消失,就必须由远程租约或看门狗决定何时停止任务。
关闭 SSH 连接会终止远程命令吗?
不能。SSH 通道关闭只会结束通道,并不保证服务器会向命令或其子进程发送 SIGTERM。PTY 可能通过终端挂起信号改变行为,但这仍不是值得依赖的清理契约。
如何终止远程 shell 脚本启动的子进程?
为每次远程运行创建独立的进程组或会话,然后向负的 PGID 发送信号,例如 kill -TERM -- -12345。依赖它之前先检查 PGID。只终止 shell PID 会留下后台子进程、管道进程和其他子进程。
应该为远程智能体命令使用 setsid 吗?
普通的 setsid 调用会为启动的命令创建新的会话和进程组,让监管程序可以终止整个进程组。不要将它用于应该在取消后继续运行的任务。使用 ps 检查生成的 SID 和 PGID,因为包装器和服务管理器可能会改变进程树。
SSH 断开或网络故障后,如何清理远程进程?
如果让远程进程继续运行会带来风险或成本,就应为它设置最长运行时间或可续期租约。租约必须存在于远程主机上,因为客户端断开后,不能再依赖它报告自己已经消失。取消信号可以快速停止任务,而租约负责处理信号始终没有到达的情况。
SSH 连接断开后,还能相信已经收到的部分输出吗?
可以通过流式输出观察过程,同时也将输出写入远程文件或日志。只有在命令退出且清理完成后,才写入单独的完成记录。流中断只能说明数据传输失败,不能说明命令是否完成。
PR_SET_PDEATHSIG 足以完成远程进程清理吗?
它只适用于 Linux,而且只能帮助进程发现自己的直接父进程已经退出。PR_SET_PDEATHSIG 不会自动覆盖孙进程,权限变化也可能清除该设置。应把它作为专用工作进程中的本地补充机制,而不是任意命令的唯一停止方式。
远程 SSH 命令应该分配 PTY 吗?
通常不需要。终端会改变信号传递、缓冲方式和程序行为,而非交互式命令能提供更干净的机器可读输出。只有当远程程序确实需要终端语义时才分配 PTY,并单独测试挂断行为。
SSH 取消操作的审计记录应包含哪些内容?
记录随机运行 ID、远程主机身份、启动 PID、PGID、SID、启动时间、请求执行的命令和最终结果。保护远程记录,避免其他用户访问,并以原子方式更新最终状态。单独的 PID 证据很弱,因为 PID 可能被复用。
智能体可以安全地通过 SSH 执行破坏性操作吗?
不要直接分发一个没有时间限制的 SSH 命令,然后在本地进程退出时把任务标记为已取消。必须要求运行句柄、进程组检查、清理期限和远程最终状态。Sallyport 可以让 SSH 凭据远离智能体,但是否能诚实地取消任务,仍取决于远程命令的设计。