阅读需 8 分钟

SSH 取消后,远程进程清理还能生效吗?

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

SSH 取消后,远程进程清理还能生效吗?

一次被取消的智能体运行,并不等于远程命令已经停止。本地进程可以正常退出,而 SSH 传输仍然保持连接;传输可能消失,而远程 shell 继续运行;shell 也可能退出,而它的子进程在另一个进程组中继续运行。如果把这些事件都压缩成一个名为“已取消”的状态,迟早会出现这样的情况:智能体报告任务已停止,但数据库迁移、软件包安装、测试工作进程或部署辅助程序仍在运行。

解决办法不是单靠一个巧妙的信号陷阱。你需要一份取消契约,明确标识远程运行,围绕其后代进程创建可终止的边界,保留足够的输出来诊断中断,并为控制端消失的情况准备远程侧方案。在允许智能体运行有副作用的命令之前,先构建并测试这份契约。

SSH 取消操作经过四个独立环节

取消请求必须跨过四个边界:智能体决定停止;本地监管程序停止或向 SSH 客户端发送信号;SSH 协议传递通道事件或信号;远程主机根据事件采取行动。每个环节都可能独立失败。

RFC 4254 区分了这些概念。它定义了通道关闭消息,也单独定义了可携带 TERMINTHUP 等名称的 signal 通道请求。通道关闭是传输事件,并不表示“向每个远程后代进程发送 SIGTERM”。RFC 还指出,在可能的情况下,关闭前发送的数据应当被交付。当笔记本进入睡眠、网络路由中断或本地进程被强制结束时,“在可能的情况下”会变得很复杂。

这个区别能揭示一个常见的错误设计:

  1. 智能体启动 ssh host long-command
  2. 用户按下取消。
  3. 智能体运行器终止本地子进程。
  4. 界面将任务标记为已取消。
  5. 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 的测试夹具刻意保持简单。它创建受保护的运行目录,让一个工作负载在新会话中启动,将输出写入文件,并在包装器收到 TERMINTHUP 时终止工作负载的进程组。

#!/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 连接结束,就报告“已取消”或“已完成”。在后续协调过程读取主机日志、部署状态、锁记录或应用特定结果之前,将运行标记为未知。

难点不在于发出一个状态词,而在于拒绝发出系统无法支持的状态词。

部分输出只能证明你观察到过,并不能证明任务完成

撤销失控的智能体会话
会话日志会显示发起 SSH 操作的智能体运行,并允许你立即撤销该会话。

数据流回答的是“客户端到目前为止收到了哪些字节”,并不回答“远程命令留下了什么状态”。当远程程序在刷新最后一个文件前打印 done,或命令已经完成但网络在客户端收到 SSH 退出状态前中断时,这个错误就会出现。

保留两类记录:

  • stdout.logstderr.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、日志位置和最终状态。

然后逐一测试这些故障路径:

  1. 向包装器 PID 发送 TERM。确认父进程和孙进程都记录了终止,并且 ps 找不到记录 PGID 中的进程。
  2. 直接向工作负载 PID 发送 TERM。确认不能假定其子进程行为安全。这个测试说明了包装器为什么要针对进程组。
  3. 终止本地 SSH 客户端,但不发送远程信号。确认工作负载会一直运行到远程租约过期。如果它立即停止,应记录原因,例如 PTY 挂断行为,而不要把这个结果视为普遍规律。
  4. 在包装器写入 cancelling:TERM 后、写入最终记录前断开连接。确认协调过程能够区分观察不完整和一个新启动的运行任务。
  5. 在同一账户下启动两个任务,取消其中一个,并证明另一个仍然存活。这能发现危险的广泛 pkill 和账户级清理逻辑。

测试时使用 pspgrep -a -g "$pgid",并在宽限期后再次检查。先检查进程表,再检查远程状态文件和日志。如果状态记录显示“已取消”,但工作进程仍然存活,这是监管程序的错误,而不是无害的报告不一致。

父进程死亡信号只在受控工作进程中有帮助

让 SSH 密钥远离上下文
Sallyport 只在执行操作时注入 SSH 凭据,不会将密钥放入智能体上下文。

Linux 提供 PR_SET_PDEATHSIG,允许进程请求内核在创建它的线程终止时发送信号。当你拥有一个小型原生辅助程序,它会启动一个直接子进程,并希望辅助程序死亡时子进程也停止,这项功能很有用。通常情况下,该设置会在 execve 后保留,但手册记录了重要例外,包括凭据变化。

它本身无法解决远程智能体清理问题。

首先,SSH 服务器进程未必是你真正关心的父进程。其次,该信号只适用于设置它的进程,不会自动覆盖所有后代。再次,远程命令一旦派生、二次派生,或将工作交给其他服务,就会脱离这种关系。最后,它是 Linux 特有的功能,如果主机环境混合使用不同 Unix 系统,这一点很重要。

只有在进程树由你控制时,才把它作为补充机制。例如,小型 Linux 工作进程可以在执行一个受控子进程前设置 PR_SET_PDEATHSIG,同时外层包装器继续负责进程组和租约。这样就有了两个作用范围不同的故障检测器,但这并不意味着可以跳过进程组边界或远程最终记录。

对工作负载中的 nohupdisownsetsid 也应采取同样的态度。有人明确希望任务在终端退出后继续运行时,它们很有用;但它们与“取消智能体运行就停止任务”的承诺不兼容。应在启动时明确选择。

第二条控制通道通常比终止第一条通道更干净

核验操作记录
加密且采用哈希链的审计日志可以通过 sp audit verify 离线验证,无需密钥。

智能体取消正在进行的 SSH 调用时,自身的本地执行上下文可能已经开始退出。依赖即将消失的进程发送最后一个协议信号,会引入竞争条件。应让独立的监管进程负责取消和协调。

一种可行的设计是:

  1. 监管程序生成密码学安全的随机运行 ID,并调用远程包装器。
  2. 包装器在以运行 ID 命名的目录中记录 PID、PGID、启动时间和状态。
  3. 监管程序在开始消费输出前,记录运行 ID 和远程主机。
  4. 取消时,监管程序打开新的控制操作,读取运行记录,核验预期所有权和运行时长,然后向记录的 PGID 发送信号。
  5. 监管程序轮询最终状态记录,直到看到终止状态或达到报告期限。

控制操作在发送任何信号前必须验证运行记录。至少要检查记录目录属于预期用户、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 凭据远离智能体,但是否能诚实地取消任务,仍取决于远程命令的设计。

Sallyport

Sallyport 替你的 AI 智能体执行 API 调用和 SSH 命令。密钥留在你 Mac 上的本地密钥库里;每次运行由你批准,每个操作都落入一份密封的审计日志。

© 2026 Sallyport · 依据 Apache-2.0 开源 · Oleg Sotnikov