SSH 命令部分失败:阻止代理重复执行操作
SSH 命令部分失败需要状态协调,而不是盲目重试。为 AI 代理记录退出码、检查点、收据和远程主机的已观测状态。

AI 代理必须把失败的 SSH 命令视为一次状态未知的转换,而不是再次运行相同文本的许可。一个命令可能已经创建用户、重新加载服务,最后却在写入最终文件时失败。盲目重试可能创建重复账户、覆盖手动修改的设置,或让本可恢复的部署变成故障。
通常的建议是「检查退出码」,这一步必要,但还不够。退出状态描述的是进程如何结束。要恢复,还需要记录进程完成了什么、主机现在报告什么状态,以及代理原本要执行哪项操作。把这些事实写入持久化命令收据,然后让代理在再次行动前先进行状态协调。
SSH 失败会留下三种不同的未知状态
一次失败的 SSH 操作可能意味着远程 shell 失败、连接失败,或者控制器停止等待。这三种情况需要不同的处理方式,但代理经常把它们都简化成「命令失败」。
假设一个远程部署脚本按以下顺序执行操作:
- 将新的应用归档写入发布目录。
- 将
current符号链接切换到该发布版本。 - 重启服务。
- 执行健康检查,因为检查发现依赖项暂时出错而返回非零状态。
即使命令返回失败,新的发布版本也可能已经上线。如果每个操作都能安全重复,重新运行脚本也许没有问题。但更常见的情况是,脚本会创建新的发布目录、截断日志、轮换凭据或执行迁移。单靠退出码无法确定到底发生了什么。
第二种情况更糟,是 SSH 传输中断。网络断开后,本地进程可能收到 255,而远程 shell 仍在继续运行。控制器超时也有同样的问题。控制器只知道自己没有拿到最终答复,不知道目标是否收到请求、shell 是否启动,或者进程是否仍在修改系统。
这个区别会改变代理接下来的动作:
- 已确认的远程退出,并且有收据时,应根据失败的检查点进行恢复。
- 传输失败时,应先观测状态,再进行任何变更。
- 控制器超时时,应先观测状态;必要时执行明确的取消流程,而不是发送重复请求。
不要把这三种情况都叫作重试。重试是一项有明确安全重复规则的操作。远程状态未知时,需要先进行状态协调。
退出状态报告的是进程,不是事务
SSH 退出码能提供有用证据,但它不是数据库提交记录。OpenSSH 的 ssh 手册说明,ssh 会返回远程命令的退出状态;如果发生错误,则返回 255。这段说明划出了许多自动化系统忽略的边界:当 SSH 收到远程退出状态时,它描述的是命令的结束情况,而 255 属于 SSH 自身的错误路径。
零退出码同样需要解释。在 POSIX shell 中,简单的顺序命令列表通常使用最后一条命令的状态。下面这个脚本可能在一次重要操作失败后仍然报告成功:
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,管道状态就是最后一条命令的状态。如果提取程序在没有收到有效数据后正常退出,下面的命令可能返回零:
curl --fail --silent https://example.invalid/build.tar.gz | tar -xz -C /srv/myapp
使用明确的解释器,并声明你需要的行为:
#!/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 脚本有意保持简单。它通过切换符号链接并重启系统服务,部署一个已经暂存好的发布版本。它并不声称能解决所有部署方式,只展示代理需要的收据机制。
#!/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,先执行只读检查。不要因为需要重试,就换几个措辞重新生成部署命令。换一种说法不会创建新的状态转换。
对于上面的部署收据,状态协调命令可以同时检查持久化记录和实时后置条件:
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
输出应采用代理能够解析的结构,不要让代理假装散文日志就是证据:
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 前退出。恢复流程可以再次运行后置条件检查,如果检查通过,就通过范围受限的恢复命令写入完成收据。它不应从头重新启动部署。
一个有用的控制器协议会把请求、结果和恢复分开。例如:
{
"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 通常已经足够:
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 支持的幂等令牌。锁必须和它保护的状态放在同一侧。
控制器在超时后重新连接时,应按以下顺序检查:
- 读取原始操作 ID 对应的收据。
- 如果操作有可靠的进程标记,检查原始进程是否仍在运行。
- 使用只读命令测试操作的后置条件。
- 选择明确的恢复动作;如果观测结果互相矛盾,则升级处理。
不要把进程是否存在作为唯一信号。进程可能仍在等待外部依赖,进程消失也无法说明它退出前改动了什么。收据和后置条件能提供更有力的证据。
SSH 多路复用同样需要谨慎。主连接可能掩盖单个命令的失败,控制器也可能把通道关闭误认为操作失败。将远程命令的 stdout、stderr、原始 SSH 退出状态、开始时间和结束时间作为一条操作记录捕获。即使代理会对 stderr 进行摘要,也要保留原始内容。原始数据经常能说明是 Bash 拒绝了未设置的变量、远程命令返回了 75,还是 SSH 自身返回了 255。
有些变更需要补偿,而不是重试
许多操作事后无法安全地变成可重复操作。凭据轮换、破坏性清理、类似支付的 API 调用和模式迁移,都需要补偿流程或人工决策。
以凭据轮换流程为例。命令可能创建新凭据、更新服务、验证访问,然后撤销旧凭据。如果它在创建后、更新服务前失败,重试可能再创建一个凭据,并留下多个仍然有效的密钥。收据应立即记录新创建凭据的标识符。恢复时可以检查服务正在使用哪个凭据,再决定更新、撤销还是保留新凭据。
不要把秘密材料写入收据、标准输出或代理上下文。只保存非秘密标识符或指纹,前提是该标识符本身不会授予访问权限。恢复流程需要知道哪个对象存在,而不是知道它的私密值。
数据库迁移还有另一种陷阱。迁移框架的历史表可以告诉你某个命名迁移已经完成,但不一定能描述框架事务之外、已经中断的数据回填。应分别检查模式变更、回填进度和完成标记。如果数据库支持当前操作所需的事务性 DDL,就使用它,但不要假设每条 DDL 语句或外部副作用都会随事务回滚。
对于对外可见的操作,优先使用 API 幂等令牌,而不是通过 SSH 猜测状态。如果远程主机调用的 API 支持幂等键,就在请求前把该令牌持久化到收据中。恢复时,根据 API 的文档语义使用同一个令牌查询 API,或带着同一个令牌重新提交。每次代理尝试都生成新令牌,会让这个功能失效。
规则很直接:如果你无法说明如何判断某项操作是否已经发生,就不要允许自治代理重试它。要求人工检查目标,或围绕持久化状态记录重新设计操作。
Shell 脚本需要一份代理能够执行的契约
供代理使用的脚本应该暴露一个范围有限、机器可读的契约。代理不擅长从冗长日志、彩色终端输出,以及混杂警告和成功消息的文本中重建状态。
使用稳定的退出类别、单一结果对象和明确的操作 ID。例如,只有在命令确认结果后,才写出最后一行 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、受保护的收据,以及一个只读后置条件检查,再做其他改动。
然后有意中断它。如果恢复路径无法判断第一次尝试是否已经切换了状态,就还没有准备好交给自治代理。不断重写操作,直到答案来自目标主机,而不是来自对退出码的信心。
常见问题
SSH 退出码究竟能告诉 AI 代理什么?
SSH 通常会返回远程命令的退出状态。这能告诉你命令 shell 是否报告成功,但不能证明之前的每个操作都没有改变主机。将非零状态视为需要检查已记录状态的信号,在重试前先完成检查。
什么时候可以自动重试失败的 SSH 命令?
只有在失败前的每个操作都具备幂等性,并且你能验证其后置条件时,才适合自动重试。刷新软件包元数据和重启指定服务可能符合条件;创建账户、修改防火墙规则或迁移数据通常需要先进行状态协调。
set -e 能防止 SSH 命令部分失败吗?
不能。set -e 会在许多错误条件下停止 shell,但条件判断、管道和命令替换周围存在明确例外。它也无法撤销后续命令失败前已经完成的操作。
为什么远程脚本应该使用 pipefail?
如果管道中的任何一个命令失败都应让脚本失败,请在 Bash 中使用 set -o pipefail。否则,curl | tar 可能只报告 tar 的状态,即使 curl 已经失败,这会让代理得到极不完整的运行结果。
SSH 命令收据应该包含什么?
保存运行标识符、预期操作、每个已完成的检查点、退出状态,以及足以支持恢复的已观测状态。除了代理的操作记录,还要把收据保存在远程主机上,因为 SSH 连接中断可能导致输出无法返回。
如何防止代理重试两次执行同一个变更?
使用由控制器提供的稳定运行 ID,把检查点以原子方式写入 root 所有的目录,并让命令在执行前读取已有收据。每次重试都重新生成随机 ID,就无法区分重试和新请求。
SSH 超时后,代理应该做什么?
超时表示控制器停止等待,并不能证明远程进程已经停止。重试前,先检查目标主机上的持久化收据、进程状态、锁文件、服务状态或事务记录。
SSH 退出码 255 表示什么?
OpenSSH 在收到远程命令退出状态时会返回该状态,而在自身发生错误时通常返回 255。代理应保留原始退出码和 stderr,然后先区分远程脚本失败与传输失败,再选择恢复方式。
代理如何判断远程变更是否已经发生?
不要只根据退出码判断。检查相关后置条件,例如查询软件包管理器、检查系统服务、读取迁移表,或比较预期配置与当前生效配置。正确的恢复操作取决于观测到的状态。
AI 代理的 SSH 操作是否应该分开记录会话日志和命令日志?
同时记录会话身份和每次独立的 SSH 操作。会话批准能说明哪个代理进程获得了权限,而调用记录能说明哪个命令针对哪个目标运行,以及返回了什么结果。混合这些记录会让事件重建更加困难。