Files
MailUI4Agents/deploy/prune-deploy-artifacts.sh
JianFeeeee 8c320121e1 措辞: 那个坑记在 8903ce5,不是「本提交下面第 33 行」(提交号写明确,避免指错)
上一笔 `d9c4946` 的注释里我写「错因就是**本提交下面第 33 行**那段自己刚记下的坑」——
但那个 `grep -c '失败'` 的坑是 `8903ce5` 记下的,不是这次提交。改成直接点提交号。
自检仍 rc=0 22/0。
2026-09-21 06:17:37 +08:00

446 lines
27 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# 清理部署残留:旧网关二进制、旧插件快照、旧数据库/附件备份、/tmp 里的构建暂存与部署前备份。
#
# # 为什么要脚本而不是手敲 rm
#
# 这些东西**天天在长**:每次 redeploy 留一个 24MB 的网关二进制、每个插件留一份快照
# (opencode/dsh 的快照含 node_modules,一份几十 MB)。2026-09-14 实测:
# /opt/agentmail 1.3G,其中旧二进制 ~790MB、插件快照 ~540MB。
# 手敲的清理不可复现、下次还得再想一遍,而且很容易漏掉"这一份还在被用"的判断。
#
# # 三条硬规矩
#
# ① **保留回滚窗口**:网关二进制留最新 N 份、每个插件留最新 N 份快照
# (`current` 永远保留)、同一时间戳的数据库/附件备份算**一个备份集**留最新 N 集。
# 发布纪律要求"有回滚目标",所以不是全清。
# ② ★ **绝不删正在使用的快照**:任何进程的 cmdline 里出现过的快照路径一律跳过。
# 删掉它 = 进程一重启就找不到自己的代码(而这类事故此前真发生过)。
# ③ ★★ **在线数据库永不入列**:`$ROOT/data/agentmail.db`(含 `-wal`/`-shm`)一旦出现在
# 删除清单里,脚本立刻**报错退出**,不做任何删除。备份删错了还能重建,
# 在线库删错了就是数据没了 —— 这条闸门必须比"我检查过了"硬。
# 判据 `--self-check` 两侧都验:干净样本删完在线库还在;把超窗口的备份换成
# 指向在线库的符号链接,脚本必须**拒跑**(不是"没删")。
#
# 用法:
# bash deploy/prune-deploy-artifacts.sh # 干跑,只报告
# bash deploy/prune-deploy-artifacts.sh --apply # 真删
# bash deploy/prune-deploy-artifacts.sh --self-check # 自检:坏样本必须红、干净样本必须绿
# KEEP_GATEWAY=2 KEEP_SNAPSHOTS=2 KEEP_BUILD_STAGES=0 bash ... --apply # 调保留窗口
set -uo pipefail
ROOT="${AGENTMAIL_ROOT:-/opt/agentmail}"
KEEP_GATEWAY="${KEEP_GATEWAY:-3}"
KEEP_SNAPSHOTS="${KEEP_SNAPSHOTS:-3}"
KEEP_DB_BACKUPS="${KEEP_DB_BACKUPS:-1}"
KEEP_TMP_DB="${KEEP_TMP_DB:-2}"
KEEP_BUILD_STAGES="${KEEP_BUILD_STAGES:-1}"
TMPD="${PRUNE_TMP_DIR:-/tmp}"
APPLY=0
[ "${1:-}" = "--apply" ] && APPLY=1
# 只影响 `del()` 用哪个删除命令(默认真 rm)。自检用它喂"删不动"的样本。
RM="${RM:-rm}"
freed_kb=0
removed=0
skipped=0
report() { printf ' %s %s\n' "$1" "$2"; }
# 在线库:唯一的"碰了就回不来"的东西。
LIVE_DB_REAL="$(readlink -f "$ROOT/data/agentmail.db" 2>/dev/null || echo "$ROOT/data/agentmail.db")"
# 收集所有"正在被进程使用"的路径(cmdline + cwd),用来做安全闸。
# 自己这条命令的 cmdline 里就可能含这个路径(例如调用方把路径写在命令行里),
# 于是"检查在用"会永远为真 —— 与 `pkill -f` 杀掉自己那条命令是同一个坑。
# 所以先把本进程及其祖先链排除掉。
_SELF_CHAIN=" "
_p=$$
while [ -n "$_p" ] && [ "$_p" != "1" ] && [ "$_p" != "0" ]; do
_SELF_CHAIN="$_SELF_CHAIN$_p "
_p=$(awk '{print $4}' "/proc/$_p/stat" 2>/dev/null || echo "")
done
in_use() {
local needle="$1" p pid
for p in /proc/[0-9]*; do
pid=${p#/proc/}
[ -r "$p/cmdline" ] || continue
case "$_SELF_CHAIN" in *" $pid "*) continue;; esac
if tr '\0' ' ' <"$p/cmdline" 2>/dev/null | grep -qF "$needle"; then return 0; fi
local cwd; cwd=$(readlink "$p/cwd" 2>/dev/null || true)
case "$cwd" in *"$needle"*) return 0;; esac
done
return 1
}
del() {
local path="$1" real
real="$(readlink -f "$path" 2>/dev/null || echo "$path")"
case "$real" in
"$LIVE_DB_REAL"|"$LIVE_DB_REAL-wal"|"$LIVE_DB_REAL-shm")
printf '\n [FATAL] 删除清单里出现在线数据库:%s(真实路径 %s)\n' "$path" "$real" >&2
printf ' 脚本的判定有 bug —— 拒绝执行,删一个备份可以重建,删在线库回不来。\n' >&2
exit 1;;
esac
local kb; kb=$(du -sk "$path" 2>/dev/null | cut -f1); kb=${kb:-0}
# ★ `rm` 的结果必须看:本脚本是 `set -uo pipefail`(**没有 -e**),
# 于是 `rm -rf` 失败(权限、只读挂载、immutable)之后脚本照样往下走,
# 先打"删除 …(NMB)"、收尾再汇总"**已删除 N 项,释放约 X MB**" ——
# **一次失败之后仍然产出成功措辞**(pi 评审 2026-09-14 抓到,实测确认)。
# 这与 `redeploy-plugin.sh` 里"被拒之后照样打 `[ OK ]`"是同一族,也是本仓库
# 反复记录的那个病:从失败里产出一份看着正常的报告。
# 所以:判失败即报错并 `exit 2`(环境/权限问题),且 `removed`/`freed_kb`
# **只在成功后才累加**,收尾汇总不再虚报。
if [ "$APPLY" = "1" ]; then
# `$RM` 只是为了**让自检能喂一个"删不动"的失败**(见 --self-check 那一段):
# 默认就是 `rm`,生产行为逐字不变。
#
# ★ 为什么**不**用 PATH shim(pi 2026-09-14 建议过,我实测后否掉,2026-09-21 补正)
#
# 我原先在这里写的原因是"`in_use` 会把命令行里含该路径的进程判成在用" ——
# **那是我没量过的推断,而且量下来是错的**:自检跑完 `正在被使用` 出现 **0** 次,
# `in_use` 根本没触发。真正的失败原因是**夹具与该路径撞了同一个时间戳**:
#
# 自检夹具建的就是 `$T/agentmail-gateway.bak-20260101-000000`(`mktree`),
# 而「窗口外那一个」恰恰也是它 —— 于是任何"对 `20260101-000000` 失败"的 shim
# 会连**夹具自己的清理**一起打掉。实测(两种 shim 都试过):
# 基线 ⇒ 通过 22 / 失败 0 (合计 22)
# shim(全 rm) ⇒ 通过 16 / 失败 6 (合计 22)
# shim(只该目标)⇒ 通过 16 / 失败 6 ← 与全量 shim **一样**,因为目标就是夹具
#
# ⚠️ 这三个数**第一版是错的**(写成 22/1、16/9),pi 2026-09-21 指出"total 恒 22,
# 22/1 与 16/9 都凑不出 22 ⇒ 算术不自洽"。他说的对,而且错因就是 `affc3ef`
# 自己在**同一份注释**里刚记下的那个坑:那三个数是用 `grep -c '失败'` 数出来的,
# 而"失败"这两个字也出现在**样本名**里
# (`坏样本(rm 删不动):必须打出那句点名失败的 [FAIL]`)和 fake-rm 的 stderr
# ⇒ 基线多数出 1、shim 多数出 3。**我在同一段注释里既写下了这个坑、
# 又用它算出的数当成了实测值。** 现已按**行首标记** `^\s*(通过|失败)\s` 精数。
# 红的六条还都是【干净样本】("窗口外没了、窗口内还在"等)—— 即 shim 把
# **本应绿的样本**打红,测到的是"夹具坏了"而不是"删除失败被报出来了"。
#
# ⇒ 让 `rm` 失败这件事,只有**注入点**能精确限定在"del() 这一次调用"上;
# PATH shim 的作用域是**整个进程**,而自检夹具活在同一个进程里,躲不开。
if ! "$RM" -rf "$path"; then
printf '
[FAIL] 删不掉:%s(权限/只读挂载/immutable?)\n' "$path" >&2
# ⚠️ 这句**不要**把收尾汇总的原话("已删除 N 项…")抄进来:
# 抄进来之后 `grep -c '已删除'` 会命中**这句报错自己**,
# 于是"有没有虚报成功"这个检查把自己的措辞当成了证据
# (与"`grep -c 用例名` 数出假数"是同一族:判据锚在了元文本上)。
printf ' 已停在中途 —— 后面的判断没跑,收尾汇总也不会执行。\n' >&2
exit 2
fi
# 删完还要**抽验**:`rm -rf` 对某些情况会"成功"却留东西(例如非空目录里
# 有不可删项时,某些实现返回 0)。存在即报错,别信退出码单一信号。
if [ -e "$path" ]; then
printf '
[FAIL] `rm -rf` 报了成功但 %s 还在\n' "$path" >&2
exit 2
fi
fi
freed_kb=$((freed_kb + kb)); removed=$((removed + 1))
report " $([ "$APPLY" = 1 ] && echo 删除 || echo 待删)" "$path($((kb/1024))MB)"
}
# ---------------------------------------------------------------- 自检
# 判据本身也要有判据:干净样本必须绿(该删的删、该留的留、在线库不动),
# 坏样本必须红(把超窗口的备份换成指向在线库的符号链接 → 必须拒跑)。
# 自检跑在临时假根上,`$TMPD` 也指到假根里 —— 绝不碰真机上的东西。
if [ "${1:-}" = "--self-check" ]; then
fails=0; total=0
ck() { total=$((total+1)); if [ "$2" = "1" ]; then echo " 通过 $1"; else echo " 失败 $1"; fails=$((fails+1)); fi; }
mktree() {
local t; t="$(mktemp -d "$TMPD/am-prune-selftest-XXXXXX")"
mkdir -p "$t/data" "$t/backups" "$t/tmp" \
"$t/plugins/demo/20260101-000000" "$t/plugins/demo/20260102-000000" \
"$t/plugins/demo/20260103-000000" "$t/plugins/demo/20260104-000000"
ln -s 20260104-000000 "$t/plugins/demo/current"
: > "$t/agentmail-gateway"
# mtime 必须各不同:`ls -1t` 对同一秒的文件靠名字兜底排序,
# 早期版本的自检就是因此把"保留哪一份"判成反的(脚本没错,鱼缸错了)。
for i in 1 2 3 4; do
echo x > "$t/agentmail-gateway.bak-2026010$i-000000"
touch -d "2026-01-0$i 00:00:00" "$t/agentmail-gateway.bak-2026010$i-000000"
touch -d "2026-01-0$i 00:00:00" "$t/plugins/demo/2026010$i-000000"
done
: > "$t/data/agentmail.db" # ← 在线库(假)
: > "$t/data/agentmail.db-wal"
: > "$t/data/agentmail.db.bak-20260105-000000" # 最新备份集 → 保留
: > "$t/backups/agentmail-20251201-000000.db" # 老备份集 → 整集删(连同附件包与 -shm/-wal)
echo yy > "$t/backups/agentmail-20251201-000000.db-shm"
: > "$t/backups/agentmail-20251201-000000.db-wal"
: > "$t/backups/attachments-20251201-000000.tar.gz"
for i in 1 2 3; do
: > "$t/tmp/agentmail-pre-deploy-2026010$i-000000.db"
touch -d "2026-01-0$i 00:00:00" "$t/tmp/agentmail-pre-deploy-2026010$i-000000.db"
# ★ 必须是**有内容的目录**,不能是 `: >` 造的空文件!
# 真实的构建暂存是 `mktemp -d` 出来的目录、里面有 `agentmail-gateway` 等产物。
# 夹具原先用 `: >` 造**普通文件**,而这个形状**恰好把下面那个 bug 藏住了**:
# `ls -1t <多个目录>` 会打 `路径:` 头 + 内容(不是裸名字),于是 collect 拿到
# 一堆不是文件名的行 ⇒ 每个都"文件名无时间戳"⇒ 全跳过 ⇒ 一个都不删,
# 而判据 197 行只查"窗口内的还在、窗口外的不在",用的是**文件名**判的,
# 于是**夹具自己认了错形状、判据自己放行了**(与 `user-question` 那次
# "判据的量纲没覆盖真实形状"是同一族)。改成真目录后这条 bug 立刻可见。
mkdir -p "$t/tmp/agentmail-gateway-build-2026010$i-000000"
: > "$t/tmp/agentmail-gateway-build-2026010$i-000000/agentmail-gateway"
touch -d "2026-01-0$i 00:00:00" "$t/tmp/agentmail-gateway-build-2026010$i-000000"
done
echo "$t"
}
# 给"删除失败"那条样本用的最小树:两个构建暂存(窗口 1 份 ⇒ 旧的应被删)
mkdtree_fail() {
local t; t="$(mktemp -d)"
mkdir -p "$t/data" "$t/tmp"
for ts in 20260101-000000 20260105-000000; do
mkdir -p "$t/tmp/agentmail-gateway-build-$ts"
: > "$t/tmp/agentmail-gateway-build-$ts/agentmail-gateway"
done
touch -d "2026-01-01 00:00:00" "$t/tmp/agentmail-gateway-build-20260101-000000"
touch -d "2026-01-05 00:00:00" "$t/tmp/agentmail-gateway-build-20260105-000000"
echo "$t"
}
run() {
local t="$1" mode="${2:-}"
# ★ 自检只允许在**自己刚建的临时假根**上跑。这条闸门是事后补的,因为它真出过事:
# 早先 `run()` 传的是 `ROOT=…`,而脚本读的是 `AGENTMAIL_ROOT` —— 前缀赋值看起来
# "传进去了"(`$TMPD` 那一路用的是 PRUNE_TMP_DIR,确实进去了),于是自检的
# `--apply` 打在了**生产根**上:删掉一批回滚备份。教训不是"下次小心",
# 而是自检必须**结构上**碰不到生产:传对变量名 + 一条根目录必须在临时区的硬闸门。
case "$t" in
"$TMPD"/am-prune-selftest-*) ;;
*) echo " [FATAL] 自检根目录不在临时区:$t —— 拒跑" >&2; exit 1;;
esac
AGENTMAIL_ROOT="$t" PRUNE_TMP_DIR="$t/tmp" KEEP_GATEWAY=2 KEEP_SNAPSHOTS=2 KEEP_DB_BACKUPS=1 KEEP_TMP_DB=1 KEEP_BUILD_STAGES=1 \
bash "$0" $mode
}
echo
echo "判据自检(先证明清理器能分辨该删/该留,再证明它拦得住误删):"
# 自检前后对比生产根的清单指纹 —— "自检不会碰生产"要有判据,不靠自觉。
_tree_sig() {
# 在线库自身的大小一直在变(WAL 在写),所以把 live 三件套排除掉再看指纹,
# 否则这条判据会因为别人的正常写入而无关红。
find "$1" -maxdepth 3 \
\( -path "$1/data/attachments" -o -name 'agentmail.db' -o -name 'agentmail.db-wal' -o -name 'agentmail.db-shm' \) -prune -o \
-printf '%p %s\n' 2>/dev/null | sort
}
SIG_BEFORE="$(_tree_sig "$ROOT")"
T="$(mktree)"
out="$(run "$T")"; rc=$? # 干跑:看报告
ck "干净样本:干跑成功(退出码 $rc)" "$([ "$rc" = 0 ] && echo 1 || echo 0)"
ck "干净样本:窗口内的网关二进制保留" "$(printf '%s' "$out" | grep -qF "保留 $T/agentmail-gateway.bak-20260104-000000" && echo 1 || echo 0)"
ck "干净样本:窗口外的网关二进制待删" "$(printf '%s' "$out" | grep -qF "待删 $T/agentmail-gateway.bak-20260101-000000" && echo 1 || echo 0)"
ck "干净样本:窗口外的插件快照待删" "$(printf '%s' "$out" | grep -qF "待删 $T/plugins/demo/20260101-000000" && echo 1 || echo 0)"
ck "干净样本:老备份集整集待删(含附件包)" \
"$(printf '%s' "$out" | grep -qF "待删 $T/backups/attachments-20251201-000000.tar.gz" && echo 1 || echo 0)"
ck "干净样本:最新备份集保留" "$(printf '%s' "$out" | grep -qF "保留 $T/data/agentmail.db.bak-20260105-000000" && echo 1 || echo 0)"
ck "干净样本:在线库不在清单里" "$(printf '%s' "$out" | grep -qE '/data/agentmail\.db(' && echo 0 || echo 1)"
run "$T" --apply >/dev/null; rc=$? # 真删:看结果
ck "干净样本:真删成功(退出码 $rc)" "$([ "$rc" = 0 ] && echo 1 || echo 0)"
ck "干净样本:在线库与它的 -wal 仍在" "$([ -f "$T/data/agentmail.db" ] && [ -f "$T/data/agentmail.db-wal" ] && echo 1 || echo 0)"
ck "干净样本:窗口外没了、窗口内还在" \
"$([ ! -e "$T/agentmail-gateway.bak-20260101-000000" ] && [ -e "$T/agentmail-gateway.bak-20260104-000000" ] && echo 1 || echo 0)"
ck "干净样本:老备份集整集删除、最新备份集保留" \
"$([ ! -e "$T/backups/agentmail-20251201-000000.db" ] && [ ! -e "$T/backups/attachments-20251201-000000.tar.gz" ] && [ -e "$T/data/agentmail.db.bak-20260105-000000" ] && echo 1 || echo 0)"
ck "干净样本:/tmp 备份只留窗口内那 1 份" \
"$([ -e "$T/tmp/agentmail-pre-deploy-20260103-000000.db" ] && [ ! -e "$T/tmp/agentmail-pre-deploy-20260101-000000.db" ] && echo 1 || echo 0)"
ck "干净样本:/tmp 构建暂存只留窗口内那 1 份" \
"$([ -e "$T/tmp/agentmail-gateway-build-20260103-000000" ] && [ ! -e "$T/tmp/agentmail-gateway-build-20260101-000000" ] && echo 1 || echo 0)"
# 坏样本:超窗口的那个备份是指向在线库的符号链接 —— 必须**拒跑**,而不是"删了才发现"
B="$(mktree)"; rm -f "$B/backups/agentmail-20251201-000000.db"
ln -s "$B/data/agentmail.db" "$B/backups/agentmail-20251201-000000.db"
bout="$(run "$B" --apply 2>&1)"; brc=$?
ck "坏样本(备份是指向在线库的链接):必须拒跑(实际退出码 $brc)" "$([ "$brc" != 0 ] && echo 1 || echo 0)"
ck "坏样本:拒绝理由要点名在线数据库" "$(printf '%s' "$bout" | grep -q "在线数据库" && echo 1 || echo 0)"
ck "坏样本:在线库仍在" "$([ -f "$B/data/agentmail.db" ] && echo 1 || echo 0)"
# 自检最要紧的一条:生产根在这两轮自检里**一个文件都没动**。
# ★ `del()` 的**失败分支**:喂一个"删不动"的 rm,verifying 三件事 ——
# 退出码 2(环境问题,不是 1)、报错点名那条路径、且**不虚报成功**。
#
# 为什么必须单独喂失败:这条分支平时不跑,而"错误分支的区分力只有喂它一个失败才存在"。
# 这一整轮修的恰好都是"错误分支第一次被调用时报错了地方"(`describeEnvError` 只覆盖
# 六处写点之一、被拒之后照样打 `[ OK ]`、go 的环境问题被说成代码问题)。
#
# 早先我以为覆盖不了它:本机以 root 跑,权限拦不住 `rm`;`unshare -r` 被拒
# (`/proc/self/uid_map: Permission denied`);tmpfs 无 `chattr +i`。
# pi 指出第三条路是**让 rm 自己失败**,而不是去改机器的权限 —— 于是有了 `$RM` 覆盖点。
# ★ 但"让 rm 失败"必须走**注入点**,不能走 PATH shim:shim 的作用域是整个进程,
# 而下面这些夹具(`mktree`)活在同一个进程里、且用的**就是** `20260101-000000`
# 这个时间戳 ⇒ shim 会把夹具自己的清理一起打掉,红的全是【干净样本】。
# 实测:基线 22 通过/0 失败;两种 shim 都退化成 16 通过/6 失败(合计恒 22,
# 详见 `del()` 里的注释 —— 那里也记了这三个数**第一版为什么是错的**)。
T2="$(mkdtree_fail)"
cat > "$T2/fake-rm" <<'FAKERM'
#!/usr/bin/env bash
case "$*" in *20260101-000000*) echo "fake rm: 模拟删除失败" >&2; exit 1;; esac
exec /bin/rm "$@"
FAKERM
chmod +x "$T2/fake-rm"
out2="$(AGENTMAIL_ROOT="$T2" PRUNE_TMP_DIR="$T2/tmp" KEEP_BUILD_STAGES=1 RM="$T2/fake-rm" \
bash "$0" --apply 2>&1)"; rc2=$?
# ⚠️ 注意这条**单独看是弱的**:变异验证时(把 `del()` 的失败判定整个去掉)
# 它**照样通过** —— 因为脚本在别处也有按 2 退出的路径。留着它是因为"退 2 而不是 1"
# 这个口径本身要守(环境问题 ≠ 判据失败),但真正的区分力来自下面两条断言。
ck "坏样本(rm 删不动):按环境问题退出(实际退出码 $rc2)" "$([ "$rc2" = 2 ] && echo 1 || echo 0)"
ck "坏样本(rm 删不动):必须打出那句点名失败的 [FAIL]" \
"$(printf '%s' "$out2" | grep -qF '[FAIL] 删不掉' && echo 1 || echo 0)"
ck "坏样本(rm 删不动):报错要点名删不掉的那条路径" \
"$(printf '%s' "$out2" | grep -qF '删不掉' && printf '%s' "$out2" | grep -qF 'agentmail-gateway-build-20260101-000000' && echo 1 || echo 0)"
ck "坏样本(rm 删不动):不许虚报成功(收尾汇总不该出现)" \
"$(printf '%s' "$out2" | grep -qE '^ 已删除 [0-9]+ 项' && echo 0 || echo 1)"
ck "坏样本(rm 删不动):那条路径必须还在" "$([ -e "$T2/tmp/agentmail-gateway-build-20260101-000000" ] && echo 1 || echo 0)"
ck "自检没有碰生产根($ROOT 清单不变)" "$([ "$(_tree_sig "$ROOT")" = "$SIG_BEFORE" ] && echo 1 || echo 0)"
rm -rf "$T" "$B"
echo
if [ "$fails" != 0 ]; then
echo " 判据自检失败 $fails/$total 项 —— 清理器本身不可信,不要用它删东西" >&2
exit 1
fi
echo " 判据自检全绿($total 项)。"
exit 0
fi
echo "==> 网关旧二进制(保留最新 $KEEP_GATEWAY 份)"
mapfile -t bins < <(ls -1t "$ROOT"/agentmail-gateway.bak-* 2>/dev/null)
for i in "${!bins[@]}"; do
[ "$i" -lt "$KEEP_GATEWAY" ] && { report " 保留" "${bins[$i]}"; continue; }
if in_use "${bins[$i]}"; then report " ★跳过(正在被使用)" "${bins[$i]}"; skipped=$((skipped+1)); continue; fi
del "${bins[$i]}"
done
echo "==> 插件快照(每个插件保留最新 $KEEP_SNAPSHOTS 份,current 永远保留)"
for plug in "$ROOT"/plugins/*/; do
[ -d "$plug" ] || continue
name=$(basename "$plug")
mapfile -t snaps < <(ls -1dt "$plug"*/ 2>/dev/null | sed 's:/$::')
for i in "${!snaps[@]}"; do
s="${snaps[$i]}"
[ "$(basename "$s")" = "current" ] && continue
# current 指向的那一份也必须保留
cur=$(readlink -f "$plug/current" 2>/dev/null || true)
[ "$cur" = "$(readlink -f "$s")" ] && { report " 保留" "$s(current 指向)"; continue; }
if [ "$i" -lt "$KEEP_SNAPSHOTS" ]; then report " 保留" "$s"; continue; fi
if in_use "$s"; then report " ★跳过(正在被使用)" "$s"; skipped=$((skipped+1)); continue; fi
del "$s"
done
# 回滚窗口的含义:每个插件除了 `current` 之外还应该留着至少一份"上一版",
# 否则回滚没有目标(发布纪律第四章)。这里只报告,**不造快照** —— 造不出来的东西
# 不该假装有。
tsnaps=0
for s in "${snaps[@]}"; do [ "$(basename "$s")" = "current" ] || tsnaps=$((tsnaps+1)); done
if [ "$tsnaps" -le 1 ]; then report " ⚠ 无回滚目标(只有 current 那一份)" "$name"; fi
done
# 数据库/附件备份。两个来源各写各的时间戳,但都是"某一刻的库快照":
# · `$ROOT/backups/agentmail-<ts>.db` + `attachments-<ts>.tar.gz` —— deploy/reset-demo.sh
# 重置演示数据前留的档;
# · `$ROOT/data/agentmail.db.bak-<ts>` —— 早期手工留档。
# 同一个 `<ts>` 的文件算**一个备份集**(库和它的附件包必须同生共死,拆开留没有意义),
# 一起进出窗口。顺序按**文件名里的时间戳**排,不按 mtime —— mtime 会被"顺手打开看一眼"
# 改掉(实测:09-02 的两个备份被 09-08 的一次访问改了 mtime)。
# 没有时间戳的文件一律不碰:判定不了就不删(宁可漏,不可误删)。
echo "==> 旧数据库/附件备份(保留最新 $KEEP_DB_BACKUPS 个备份集)"
declare -A SET_MEMBERS=()
for f in "$ROOT"/backups/* "$ROOT"/data/*.bak-*; do
[ -e "$f" ] || continue
ts="$(printf '%s' "$(basename "$f")" | grep -oE '[0-9]{8}-[0-9]{6}' | head -1 || true)"
if [ -z "$ts" ]; then
report " 跳过(文件名无时间戳,判定不了)" "$f"; skipped=$((skipped+1)); continue
fi
SET_MEMBERS[$ts]="${SET_MEMBERS[$ts]:-} $f"
done
if [ "${#SET_MEMBERS[@]}" -eq 0 ]; then
report " (没有备份集)" ""
else
mapfile -t sets < <(printf '%s\n' "${!SET_MEMBERS[@]}" | sort -r)
for i in "${!sets[@]}"; do
ts="${sets[$i]}"
# ★ **整集一起判"在用"**,不能只跳那一个文件(pi 评审 2026-09-14 指出,实测确认)。
# 原先 `in_use "$f"` 命中就 `continue` —— 只跳过**那一个**,同一 `<ts>` 的其余成员
# 照删。备份集被拆开,与这段自己的注释("拆开留没有意义")相反;
# 在**删除**那一支(`i >= KEEP_DB_BACKUPS`)后果更难看:正在被使用的那个留了下来,
# 而同一时间戳的 `-wal`/`-shm` 被删掉 —— **"留"的那份配上被删的兄弟才是完整一套**,
# 结果留下的是残的。所以先算一次整集的在用状态,再决定整集动不动。
set_in_use=0
for f in ${SET_MEMBERS[$ts]}; do
if in_use "$f"; then set_in_use=1; break; fi
done
if [ "$set_in_use" -eq 1 ]; then
report " ★跳过整集(有成员正在被使用)" "$ts"
for f in ${SET_MEMBERS[$ts]}; do skipped=$((skipped+1)); done
continue
fi
for f in ${SET_MEMBERS[$ts]}; do
if [ "$i" -lt "$KEEP_DB_BACKUPS" ]; then
case "$f" in
# 保留集里的 `-shm`/`-wal` 是"打开看过一眼"的残渣:`-shm` 是内存索引,
# sqlite 自己会重建;`-wal` 只在**非空**时才可能含未合并写入,非空就不动它。
*-shm) del "$f";;
*-wal) if [ -s "$f" ]; then report " *跳过(wal 非空,可能含未合并写入)" "$f"; skipped=$((skipped+1)); else del "$f"; fi;;
*) report " 保留" "$f";;
esac
else
del "$f"
fi
done
done
fi
# 构建暂存:`redeploy-gateway.sh` 先把网关构建到 `$TMPD` 再 `install` 过去(为了原子
# 替换),**用完没有任何人删过它** —— 每次部署留下一个 24MB。2026-09-14 实测 7 份
# / 162MB。这不是"几百 MB 而已":本机 `$TMPD` 是 9.8G 的 tmpfs,当时已被占满
# (可用 0 字节),连 `go build` 都进不去 —— 而部署的第一步就是构建。
#
# 窗口留 1 份而不是 0:正常路径下部署脚本自己会收(见 redeploy-gateway.sh 末尾),
# 留下的只可能是**失败**的那次;失败时那一份正好用来事后查,删了就没现场了。
echo "==> $TMPD 的构建暂存(保留最新 $KEEP_BUILD_STAGES 份)"
# ★ `-d` 是**必须的**,不是风格:`agentmail-gateway-build-*` 是**目录**(`mktemp -d` 造的),
# 而 `ls -1t <多个目录>` 的默认行为是**列出每个目录的内容并打 `路径:` 头** ——
# 于是 `mapfile` 收到的是一堆 `…000000:`、`agentmail-gateway` 这样的行,
# 全都不是文件名 ⇒ 后面 `basename | grep -oE '[0-9]{8}-[0-9]{6}'` 取不到时间戳
# ⇒ 每个都判"文件名无时间戳,判定不了"⇒ **一个都不删**。
# 后果正是本段注释开头写的那个问题("每次部署留下一个 24MB")**从来没被清理过**:
# 2026-09-14 实测 /tmp 里确实还躺着 1 份 24MB(`…-20260914-190530`),
# 而 162MB 那次的现场早就没了 —— 也就是说这段清理**一直是空转的**。
# 之所以一直没人发现:自检夹具用 `: >` 造的是**普通文件**,形状与生产不一致(已一并修正)。
mapfile -t stages < <(ls -1dt "$TMPD"/agentmail-gateway-build-* 2>/dev/null)
for i in "${!stages[@]}"; do
[ "$i" -lt "$KEEP_BUILD_STAGES" ] && { report " 保留" "${stages[$i]}"; continue; }
# 二进制可能正被某个部署进程 install 到一半 —— 与快照同理,用过的路径一律先问一句。
if in_use "${stages[$i]}"; then report " ★跳过(正在被使用)" "${stages[$i]}"; skipped=$((skipped+1)); continue; fi
del "${stages[$i]}"
done
echo "==> $TMPD 的部署前数据库备份(保留最新 $KEEP_TMP_DB 份)"
mapfile -t dbs < <(ls -1t "$TMPD"/agentmail-pre-deploy-*.db 2>/dev/null)
for i in "${!dbs[@]}"; do
[ "$i" -lt "$KEEP_TMP_DB" ] && { report " 保留" "${dbs[$i]}"; continue; }
del "${dbs[$i]}"
done
# 会话清理(prune-test-sessions.sh)前的备份是**那次删除**唯一的回滚点,
# 所以另算一条窗口、且窗口到了也要说清楚它是什么再删。
echo "==> $TMPD 的会话清理前备份(保留最新 $KEEP_TMP_DB 份)"
mapfile -t prunedbs < <(ls -1t "$TMPD"/agentmail-pre-prune-*.db 2>/dev/null)
for i in "${!prunedbs[@]}"; do
[ "$i" -lt "$KEEP_TMP_DB" ] && { report " 保留(会话归档的回滚点)" "${prunedbs[$i]}"; continue; }
del "${prunedbs[$i]}"
done
echo
if [ "$APPLY" = "1" ]; then
echo " 已删除 $removed 项,释放约 $((freed_kb/1024))MB;跳过(在用/不能判)$skipped 项"
echo " 已确认:删除清单不含在线数据库 $(basename "$LIVE_DB_REAL")。"
else
echo " 干跑:将删除 $removed 项,可释放约 $((freed_kb/1024))MB;跳过(在用/不能判)$skipped 项"
echo " 已确认:删除清单不含在线数据库 $(basename "$LIVE_DB_REAL")。"
echo " 加 --apply 执行。"
fi