# SSH 命令部分失败：阻止代理重复执行操作

AI 代理必须把失败的 SSH 命令视为一次状态未知的转换，而不是再次运行相同文本的许可。一个命令可能已经创建用户、重新加载服务，最后却在写入最终文件时失败。盲目重试可能创建重复账户、覆盖手动修改的设置，或让本可恢复的部署变成故障。

通常的建议是「检查退出码」，这一步必要，但还不够。退出状态描述的是进程如何结束。要恢复，还需要记录进程完成了什么、主机现在报告什么状态，以及代理原本要执行哪项操作。把这些事实写入持久化命令收据，然后让代理在再次行动前先进行状态协调。

## SSH 失败会留下三种不同的未知状态

一次失败的 SSH 操作可能意味着远程 shell 失败、连接失败，或者控制器停止等待。这三种情况需要不同的处理方式，但代理经常把它们都简化成「命令失败」。

假设一个远程部署脚本按以下顺序执行操作：

1. 将新的应用归档写入发布目录。
2. 将 `current` 符号链接切换到该发布版本。
3. 重启服务。
4. 执行健康检查，因为检查发现依赖项暂时出错而返回非零状态。

即使命令返回失败，新的发布版本也可能已经上线。如果每个操作都能安全重复，重新运行脚本也许没有问题。但更常见的情况是，脚本会创建新的发布目录、截断日志、轮换凭据或执行迁移。单靠退出码无法确定到底发生了什么。

第二种情况更糟，是 SSH 传输中断。网络断开后，本地进程可能收到 255，而远程 shell 仍在继续运行。控制器超时也有同样的问题。控制器只知道自己没有拿到最终答复，不知道目标是否收到请求、shell 是否启动，或者进程是否仍在修改系统。

这个区别会改变代理接下来的动作：

- 已确认的远程退出，并且有收据时，应根据失败的检查点进行恢复。
- 传输失败时，应先观测状态，再进行任何变更。
- 控制器超时时，应先观测状态；必要时执行明确的取消流程，而不是发送重复请求。

不要把这三种情况都叫作重试。重试是一项有明确安全重复规则的操作。远程状态未知时，需要先进行状态协调。

## 退出状态报告的是进程，不是事务

SSH 退出码能提供有用证据，但它不是数据库提交记录。OpenSSH 的 `ssh` 手册说明，`ssh` 会返回远程命令的退出状态；如果发生错误，则返回 255。这段说明划出了许多自动化系统忽略的边界：当 SSH 收到远程退出状态时，它描述的是命令的结束情况，而 255 属于 SSH 自身的错误路径。

零退出码同样需要解释。在 POSIX shell 中，简单的顺序命令列表通常使用最后一条命令的状态。下面这个脚本可能在一次重要操作失败后仍然报告成功：

```sh
install -m 0644 app.conf /etc/myapp/app.conf
systemctl restart myapp
logger -t deploy "deployment finished"
```

如果 `install` 失败，但 `systemctl restart` 和 `logger` 都返回零，脚本的最终状态就是零。代理会看到成功，并错误地认为配置已经发生变化。最后添加一个 `echo done` 也会制造同样的假象。

管道会带来另一条失败路径。Bash Reference Manual 说明，在 Bash 中，如果没有启用 `pipefail`，管道状态就是最后一条命令的状态。如果提取程序在没有收到有效数据后正常退出，下面的命令可能返回零：

```bash
curl --fail --silent https://example.invalid/build.tar.gz | tar -xz -C /srv/myapp
```

使用明确的解释器，并声明你需要的行为：

```bash
#!/usr/bin/env bash
set -Eeuo pipefail

curl --fail --silent --show-error "$archive_url" | tar -xz -C "$release_dir"
```

`-e` 会让 Bash 在许多未处理的失败后停止，`-u` 会拒绝使用未设置的变量，`pipefail` 会保留管道前面命令的失败状态。`E` 选项让 `ERR` 陷阱在函数和命令替换中也能生效。这些设置能改善失败报告，但不会让一连串操作具备原子性。

最后一点很重要。`set -e` 是在某个操作返回错误后才执行的。它无法撤销之前命令创建的目录，也无法恢复之前命令重启的服务。它还有一些容易令人意外的例外：由 `if` 检查的命令、位于 `&&` 或 `||` 左侧的命令，以及若干复合上下文中的命令，不一定会导致 shell 退出。对于会改变恢复决策的操作，请写出明确的检查。

## 在编写命令前定义操作边界

代理无法恢复「部署服务」这种模糊指令。远程命令需要一个命名操作，并提供观察者可以验证的后置条件。

对于发布版本变更，操作可以是：「将 `/srv/myapp/current` 设置为发布版本 `2025-04-18.3`，然后确认运行中的服务报告该版本。」对于数据库变更，可以是：「只应用一次迁移 `add_invoice_index`，并确认迁移记录已经存在。」命令文本只是实现细节。操作及其后置条件决定了能否进行恢复。

在不可逆或对外可见的边界处分割操作。检查点不必对应每一行 shell 命令。好的检查点应记录会改变下一步决策的状态变化。在部署中，这些检查点可能包括：归档已验证、发布目录已填充、符号链接已切换、服务已重启，以及健康状态已观测。

不要接受「让每个远程命令都幂等，然后无限重试」这种流行但错误的建议。幂等性只适用于特定操作和明确的目标状态。`mkdir -p /srv/app` 可能可以重复执行。`useradd deploy` 只有在代理检查现有账户是否拥有预期的 UID、组、主目录和 shell 时，才可能安全重复。`ALTER TABLE` 第二次执行可能失败，或者更糟，写得不严谨的迁移可能重复应用相关变更。

命令本身可以安全重复，但它所在的工作流未必安全。重启服务也许可以重复执行，但在配置复制到一半时重启服务，可能会让不完整的文件对外生效。把状态验证放在操作旁边，不要让代理根据通用规则自行推断。

在授予访问权限前，为每个操作定义四个字段：

- 在恢复期间保持不变的操作 ID。
- 可由只读命令检查的目标后置条件。
- 描述已完成状态变化的检查点。
- 每个未完成检查点对应的恢复动作。

操作 ID 不是装饰。如果控制器每次尝试都创建新的标识符，目标就无法区分继续执行和新请求。这正是重复迁移和重复配置资源发生的原因。

## 在每次状态变化前后写入收据

持久化收据能把部分失败变成可检查的事件。在第一次变更前将收据写入目标主机，在每个有意义的检查点后更新，并以原子方式完成更新。

下面的 Bash 脚本有意保持简单。它通过切换符号链接并重启系统服务，部署一个已经暂存好的发布版本。它并不声称能解决所有部署方式，只展示代理需要的收据机制。

```bash
#!/usr/bin/env bash
set -Eeuo pipefail

operation_id=${1:?operation ID required}
release=${2:?release path required}
service=${3:?service name required}
state_dir=/var/lib/agent-ops
receipt="$state_dir/$operation_id.receipt"
tmp="$receipt.$$"

mkdir -p "$state_dir"
chmod 0700 "$state_dir"

write_receipt() {
  cat >"$tmp" <<EOF
operation_id=$operation_id
release=$release
service=$service
checkpoint=$1
updated_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
EOF
  chmod 0600 "$tmp"
  mv -f "$tmp" "$receipt"
}

fail() {
  status=$?
  write_receipt "failed:$status"
  exit "$status"
}
trap fail ERR

if [[ -f "$receipt" ]]; then
  . "$receipt"
  case "$checkpoint" in
    complete)
      printf 'operation already complete: %s\n' "$operation_id"
      exit 0
      ;;
    switched|restarted)
      printf 'operation requires reconciliation: %s\n' "$checkpoint" >&2
      exit 75
      ;;
  esac
fi

[[ -d "$release" ]]
write_receipt "release_verified"

ln -sfn "$release" /srv/myapp/current
write_receipt "switched"

systemctl restart "$service"
write_receipt "restarted"

active_target=$(readlink -f /srv/myapp/current)
[[ "$active_target" == "$release" ]]
systemctl is-active --quiet "$service"
write_receipt "complete"
printf 'operation complete: %s\n' "$operation_id"
```

临时文件和 `mv` 很重要。在同一文件系统中，重命名会通过一次操作替换收据，因此读取者拿到的要么是之前完整的收据，要么是新的完整收据，不会读到半截文件。只让拥有该操作的账户写入状态目录。如果不受信任的用户可以编辑收据，代理的恢复逻辑就会把虚构内容当成证据。

`ERR` 陷阱会在 Bash 处理失败时记录退出状态。但如果机器断电、收到无法捕获的信号，或突然崩溃，它就无法运行。因此，脚本应在每个已完成的状态变化后记录进度，不能只依赖最后的陷阱。

除非目录的所有权和权限受到严格保护，否则不要像这个小例子一样加载任意收据格式。在生产环境中，优先使用已知解析器解析的 JSON，或使用会拒绝意外字段的固定行格式。这个例子只加载自己在受保护目录中创建的文件，以便让 shell 代码更易读。

收据应该记录已观测的事实，而不是乐观的意图。`checkpoint=switched` 表示符号链接命令成功返回，但不表示服务已经加载新的发布版本。`complete` 则是在明确检查后置条件之后写入的。这种区别能防止代理把已经写出的命令当成已完成的操作。

## 让代理请求状态协调，而不是发送新命令

收到非零结果后，代理应保留原始操作 ID，先执行只读检查。不要因为需要重试，就换几个措辞重新生成部署命令。换一种说法不会创建新的状态转换。

对于上面的部署收据，状态协调命令可以同时检查持久化记录和实时后置条件：

```bash
operation_id='release-7f3b'
cat "/var/lib/agent-ops/$operation_id.receipt"
printf 'current='
readlink -f /srv/myapp/current
systemctl is-active myapp
systemctl show myapp --property=ActiveState --property=SubState --no-pager
```

输出应采用代理能够解析的结构，不要让代理假装散文日志就是证据：

```text
operation_id=release-7f3b
release=/srv/myapp/releases/2025-04-18.3
service=myapp
checkpoint=restarted
updated_at=2025-04-18T14:05:12Z
current=/srv/myapp/releases/2025-04-18.3
active
ActiveState=active
SubState=running
```

这里，符号链接指向请求的发布版本，服务也处于活动状态，但收据停在 `restarted`。远程进程可能在重启服务后、写入 `complete` 前退出。恢复流程可以再次运行后置条件检查，如果检查通过，就通过范围受限的恢复命令写入完成收据。它不应从头重新启动部署。

一个有用的控制器协议会把请求、结果和恢复分开。例如：

```json
{
  "operation_id": "release-7f3b",
  "action": "deploy_release",
  "target": "app-01",
  "arguments": {
    "release": "/srv/myapp/releases/2025-04-18.3",
    "service": "myapp"
  },
  "mode": "reconcile"
}
```

目标只应接受 `mode: reconcile` 用于只读检查，或用于会验证后置条件的预先编写好的完成路径。不要让代理发送一段任意 shell 字符串，再把它标记为 `reconcile`。当命令可以执行任何变更时，这个标签没有安全意义。

示例中的退出码 75 是有意设计的临时失败信号。具体数字没有约定本身重要，重要的是明确的契约：代理看到这个结果后必须观测状态，而不是自动提交重试。控制器应为 `completed`、`reconcile_required`、`rejected_before_start` 和 `transport_unknown` 保留不同结果。单一的布尔值 `success` 会摧毁恢复所需的信息。

## 超时和断开连接需要证明远程状态

超时是本地观测。本地客户端放弃了等待，但不一定停止了远程命令。把超时当成取消，是重复执行远程操作最快的方式之一。

控制器可以让操作保持单飞，从而减少歧义。在执行变更前，远程脚本创建一个与操作 ID 关联的排他锁。后续尝试看到锁后，可以选择等待、检查进程，或报告需要人工决策的恢复状态。

对于简单的主机级锁，`flock` 通常已经足够：

```bash
exec 9>/var/lib/agent-ops/deploy.lock
if ! flock -n 9; then
  printf 'another deployment operation is active\n' >&2
  exit 75
fi
```

这只保护遵守同一把锁的进程。它无法防止管理员使用另一套部署流程，也不能解决脚本设计中的所有错误。数据库迁移应尽可能使用数据库自己的咨询锁或迁移锁。API 操作应使用 API 支持的幂等令牌。锁必须和它保护的状态放在同一侧。

控制器在超时后重新连接时，应按以下顺序检查：

1. 读取原始操作 ID 对应的收据。
2. 如果操作有可靠的进程标记，检查原始进程是否仍在运行。
3. 使用只读命令测试操作的后置条件。
4. 选择明确的恢复动作；如果观测结果互相矛盾，则升级处理。

不要把进程是否存在作为唯一信号。进程可能仍在等待外部依赖，进程消失也无法说明它退出前改动了什么。收据和后置条件能提供更有力的证据。

SSH 多路复用同样需要谨慎。主连接可能掩盖单个命令的失败，控制器也可能把通道关闭误认为操作失败。将远程命令的 stdout、stderr、原始 SSH 退出状态、开始时间和结束时间作为一条操作记录捕获。即使代理会对 stderr 进行摘要，也要保留原始内容。原始数据经常能说明是 Bash 拒绝了未设置的变量、远程命令返回了 75，还是 SSH 自身返回了 255。

## 有些变更需要补偿，而不是重试

许多操作事后无法安全地变成可重复操作。凭据轮换、破坏性清理、类似支付的 API 调用和模式迁移，都需要补偿流程或人工决策。

以凭据轮换流程为例。命令可能创建新凭据、更新服务、验证访问，然后撤销旧凭据。如果它在创建后、更新服务前失败，重试可能再创建一个凭据，并留下多个仍然有效的密钥。收据应立即记录新创建凭据的标识符。恢复时可以检查服务正在使用哪个凭据，再决定更新、撤销还是保留新凭据。

不要把秘密材料写入收据、标准输出或代理上下文。只保存非秘密标识符或指纹，前提是该标识符本身不会授予访问权限。恢复流程需要知道哪个对象存在，而不是知道它的私密值。

数据库迁移还有另一种陷阱。迁移框架的历史表可以告诉你某个命名迁移已经完成，但不一定能描述框架事务之外、已经中断的数据回填。应分别检查模式变更、回填进度和完成标记。如果数据库支持当前操作所需的事务性 DDL，就使用它，但不要假设每条 DDL 语句或外部副作用都会随事务回滚。

对于对外可见的操作，优先使用 API 幂等令牌，而不是通过 SSH 猜测状态。如果远程主机调用的 API 支持幂等键，就在请求前把该令牌持久化到收据中。恢复时，根据 API 的文档语义使用同一个令牌查询 API，或带着同一个令牌重新提交。每次代理尝试都生成新令牌，会让这个功能失效。

规则很直接：如果你无法说明如何判断某项操作是否已经发生，就不要允许自治代理重试它。要求人工检查目标，或围绕持久化状态记录重新设计操作。

## Shell 脚本需要一份代理能够执行的契约

供代理使用的脚本应该暴露一个范围有限、机器可读的契约。代理不擅长从冗长日志、彩色终端输出，以及混杂警告和成功消息的文本中重建状态。

使用稳定的退出类别、单一结果对象和明确的操作 ID。例如，只有在命令确认结果后，才写出最后一行 JSON：

```json
{"operation_id":"release-7f3b","outcome":"reconcile_required","checkpoint":"switched","exit_code":75}
```

如果控制器能够执行这一约定，就把普通诊断输出放到 stderr，并将 stdout 保留给结果记录。把横幅、进度条和 JSON 都输出到 stdout 的 shell 命令，很容易造成解析失败。如果命令需要输出有用的进度，先写入持久化收据，并让控制器把最终结构化记录视为便利信息，而不是唯一记录。

不要让代理自行选择任意检查点名称、收据路径、服务名称或解释器。应暴露经过审核、参数受限的命令。接受 `--` 后自由执行 shell 的命令包装器，只是把问题藏在了更好看的标签后面。

命令契约还应说明哪些失败可以安全重试。例如，在主机发生变化前，软件包下载可能返回可重试的网络错误。符号链接切换后连接丢失，则必须先通过状态协调验证链接目标，之后才能重试。这种分类应由了解操作影响的作者定义，而不是让模型根据 stderr 猜测。

在测试主机上运行脚本，并在每个检查点中断它。在归档提取期间、符号链接切换后、重启期间以及最终健康检查后分别发送终止信号。然后运行状态协调路径，检查它是否得出了正确决策。如果你从未有意中断过一次操作，就无法知道它的重试行为是否安全。

## 授权和审计记录必须保留恢复过程

代理需要有权执行恢复读取，但如果读取结果导致新的变更，该变更仍应遵循正常的授权边界。不要把第二次部署藏在名为 `status` 的命令中。

Sallyport 可以把 SSH 凭据放在代理之外，并同时记录代理运行和每次操作，从而帮助保留谁批准了恢复尝试，以及它发送了什么命令。它的审计轨迹不能替代目标侧收据：审计记录可以证明操作请求发生过，而收据和后置条件才能说明目标最终处于什么状态。

在事件记录中分开保存这些信息。会话身份能回答哪个代理进程拥有权限。单次操作记录能回答哪个命令针对哪个主机运行，以及返回了什么。目标收据能回答该操作停在了哪里。部署出问题时，把这些事实压缩成一段聊天记录，只会浪费恢复所需的证据。

对于不可逆影响的操作，尤其是恢复可能导致凭据删除、模式修复或清理时，应要求每次调用获得确认。当代理的证据互相冲突时，额外批准很有价值。例如，收据说切换已完成，但活动服务仍报告旧版本。这是决策点，不是常规重试。

好的恢复设计会让保守动作变得简单。为代理提供只读状态协调命令、持久化操作 ID 和明确的升级结果。这样，连接中断会留下可检查的记录，而不是触发再次改变系统的第二次尝试。

## 从修复团队已经在重试的命令开始

找出团队在超时或部署信息变红后会重新运行的 SSH 命令。先添加操作 ID、受保护的收据，以及一个只读后置条件检查，再做其他改动。

然后有意中断它。如果恢复路径无法判断第一次尝试是否已经切换了状态，就还没有准备好交给自治代理。不断重写操作，直到答案来自目标主机，而不是来自对退出码的信心。
