# SSH 取消后，远程进程清理还能生效吗？

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

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

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

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

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

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

1. 智能体启动 `ssh host long-command`。
2. 用户按下取消。
3. 智能体运行器终止本地子进程。
4. 界面将任务标记为已取消。
5. `long-command` 或其某个子进程继续在远程主机上运行。

第五步不是边缘情况。只要服务器没有理由终止命令，或者命令在连接消失前脱离了会话，它通常就是默认结果。

一份有用的取消契约应明确说明本地端会尝试什么，以及远程端负责什么：

- 启动器使用随机运行 ID 创建一个可识别的远程运行。
- 远程包装器让工作负载运行在独立的进程组或会话中。
- 正常取消时向该进程组发送 `TERM`，并记录结果。
- 只有经过明确规定的宽限期后，包装器才升级为 `KILL`。
- 如果控制端始终没有回来，远程期限或租约会结束任务。
- 输出和最终状态不会依赖 SSH 流才能保留。

在得到以下两种结果之一之前，不要把任务称为已取消：已确认的远程最终记录，或明确的“状态未知”结果。连接丢失后假装确定，会让故障响应变慢，因为所有人都从一个错误前提开始调查。

## 只有远程 PID 不足以清理子进程

只有在 shell 从不派生进程、不启动管道、不运行后台进程，也不调用会创建辅助进程的工具时，终止远程 shell PID 才安全。这类命令很少。

看一个普通的远程命令：

```sh
build-assets | tee build.log &
wait
```

shell 只有一个 PID，但管道中有多个进程。shell 退出后，`tee` 可能仍在写日志。编译器可能启动工作进程，软件包管理器可能把工作交给服务。如果执行 `kill -TERM "$shell_pid"`，你只是终止了更大进程组中的一个成员，对其他进程几乎一无所知。

对于短期远程运行，进程组能提供正确的取消单位。在 Linux 中，每个进程属于一个进程组，每个进程组属于一个会话。终端生成的信号会发送给前台进程组，所以终端行为看起来可能比实际更神秘。Linux 的 `setpgid(2)` 文档也明确说明，除非发生变化，子进程会继承父进程的进程组。

对于由智能体控制的命令，应为工作负载创建新会话。会话领导者通常拥有与 PGID 和 SID 相同的 PID。这样，`kill` 中的负 PID 就可以指向整个进程组：

```sh
kill -TERM -- -"$pgid"
```

开头的负号决定了你终止的是一个进程还是它的进程组。`--` 同样重要，它可以避免格式错误的值被解析成选项。

不要想当然地认为工作负载 PID 就是进程组 ID。在启动时检查它。shell 包装器、服务管理器或调用 `setpgid` 的程序都可能改变进程树。测试工具中至少应保留这条检查命令：

```sh
ps -o pid=,ppid=,pgid=,sid=,stat=,etime=,command= -p "$pid"
```

典型输出如下：

```text
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` 时终止工作负载的进程组。

```bash
#!/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`：

```bash
#!/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 会话中检查进程树和运行目录：

```sh
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` 和账户级清理逻辑。

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

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

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

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

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

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

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

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

智能体取消正在进行的 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` 及时执行，也可能发生在数据库事务提交之后。进程清理只能回答工作进程是否仍在执行，不能撤销外部副作用。

控制面应保留三种结果：已完成、已确认清理的已取消，以及未知。未知令人不舒服，但它能告诉下一位操作人员应该怎么做：在发出新操作前检查远程状态。这远胜于依靠侥幸的绿色重试按钮。

当你能够在每个边界中断取消操作，并解释残留的进程树、磁盘上的输出、最终记录和重试决定时，取消功能才算准备就绪。如果在临时主机上都做不到这些，就不要在凌晨两点信任它处理生产主机。
