fix(prune)!: 构建暂存那段清理**一直是空转的**(ls -1t 对目录打 路径: 头)+ del() 失败分支补齐覆盖

## 那个真 bug:`ls -1t <多个目录>` 不打裸名字

追 pi 的 shim 建议时撞出来的,与 shim 无关 —— 是我为了给它造样本才发现的:

    ls -1t /tmp/agentmail-gateway-build-*     # 这些是**目录**(mktemp -d 造的)
    /tmp/…-20260101-000000:
    agentmail-gateway
    /tmp/…-20260105-000000:
    agentmail-gateway

`ls -1t` 收到**多个目录参数**时会列出**每个目录的内容**并打 `路径:` 头 ——
于是 `mapfile` 拿到的全是 `…000000:` / `agentmail-gateway` 这类行,都不是文件名
⇒ `basename | grep -oE '[0-9]{8}-[0-9]{6}'` 取不到时间戳 ⇒ 每条都判
"文件名无时间戳,判定不了" ⇒ **一个都不删**。
也就是说本段注释里写的那个问题("每次部署留下一个 24MB")**从来没被清理过**。
修法:`ls -1dt`(`-d` 让目录自身作为条目,不打头)。

**为什么一直没人发现**:自检夹具用 `: >` 造的是**普通文件**,而生产是**有内容的目录**。
夹具形状与生产不一致 ⇒ 夹具自己认了错形状,而判据 197 行又只按**文件名**判
"窗口内的还在、窗口外的不在",于是判据也认了。这是 docs 第 13 条那一族。
→ 夹具已改成真目录 + 里面放 `agentmail-gateway`;**改完立刻变红**("干净样本:/tmp 构建暂存
只留窗口内那 1 份"失败),证明夹具现在真的有分辨力,然后加 `-d` 转绿。

顺带确认:其余三处 `ls -1t`(`agentmail-gateway.bak-*`、`pre-deploy-*.db`、`pre-prune-*.db`)
glob 到的是**文件**,不受影响;插件快照那处(第 294 行)本来就已经写了 `-1dt`。
生产现场实测:`/tmp` 下确实还躺着 1 份 24MB 暂存没被收掉。

## `del()` 的失败分支:采纳 pi 的"让 rm 自己失败"

他指出的第三条路(我原先只想到 immutable 与注入点)是对的:本机以 root 跑、权限拦不住;
`unshare -r` 被拒(`/proc/self/uid_map: Permission denied`);tmpfs 无 `chattr +i`。
**改机器的权限**不如**让 rm 失败**。

实现上走了 `RM="${RM:-rm}"` 而不是 PATH shim,理由:`in_use` 会把命令行里含该路径的进程
判成"在用",而自检必须把路径写在命令行上 —— 实测评 PATH shim 时确实被 `in_use` 挡掉、
`del()` 根本没被调用(那次"测试通过"是假的)。`$RM` 默认就是 `rm`,生产行为逐字不变。

自检新增 4 条(并通过变异确认有区分力:去掉失败判定 ⇒ 强断言变红):
退出码 2、必须打出 `[FAIL] 删不掉`、不许出现收尾汇总、那条路径必须还在。

★ 变异还暴露出一条**弱断言**:单看"退出码 = 2"在变异后**照样通过**(脚本别处也有退 2 的路径)
—— 已在注释里注明它弱、区分力来自另两条,没有让它冒充证据。

★ 顺手修掉一处自指的措辞:我原先在报错里抄了收尾汇总的原话("已删除 N 项"),
于是 `grep -c '已删除'` 命中**这句报错自己** ⇒ "有没有虚报成功"这个检查把自己的措辞
当成了证据。改写成不含该字面量的说法(与 `grep -c 用例名` 是同一族:判据锚在元文本上)。

## pi 的另两点

· `diffSummary` 带 `ctx` 时**会读文件**(判 `scripts.test` 要读两侧原文),不带是纯内存比较
  —— 已写进函数头,免得以后有人当纯函数用而在大树上意外吃到 I/O。
· "自检样本不独立"的三种形态(位置选择器 / 共享夹具状态泄漏 / 探针无分辨力)
  **合成 docs 第 13 条**(修法同一个:显式命名 + 显式复位),并把上面"夹具形状必须与生产
  一致"作为配套一条写进同一条 —— 今天的真 bug 正是它。

验证:prune 自检 22/22、干跑 exit 0;npm test exit 0;drift 自检 35/0;check-shared-libs exit 0。
This commit is contained in:
2026-09-14 20:53:54 +08:00
parent 7eec311756
commit 743e397916
3 changed files with 99 additions and 11 deletions

View File

@ -198,6 +198,15 @@ export function jsonTestOnlyChange(repoText, snapText) {
return { testOnly: diffs.length > 0 && diffs.every((k) => k.startsWith('scripts.test')), keys: diffs };
}
/**
* ⚠️ **带 `ctx` 时会读文件;不带是纯内存比较**(pi 提醒 2026-09-14)。
*
* 判 `scripts.test` 这类字段必须读两侧**原文**,所以 `diffSummary(d, {repoDir, snapDir})`
* 不是纯函数 —— 它会对 `d.changed.runtime` 里的每个文件各读一次。
* 拿它当纯函数用(例如在大树上反复调、或放进"只算不读"的路径)会意外吃到 I/O。
* 不带 ctx 时 `testOnly` 恒为 `[]`、不读任何文件,行为与旧版逐字相同
* (既有那批自检样本正是走这条路,必须保持住)。
*/
export function diffSummary(d, ctx = {}) {
// `testOnly` 只在给出两棵树根目录时才有意义(要读原文比对字段)。
const testOnly = (ctx.repoDir && ctx.snapDir) ? testOnlyDrift(d, ctx.repoDir, ctx.snapDir) : [];

View File

@ -37,6 +37,8 @@ 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
@ -90,10 +92,17 @@ del() {
# 所以:判失败即报错并 `exit 2`(环境/权限问题),且 `removed`/`freed_kb`
# **只在成功后才累加**,收尾汇总不再虚报。
if [ "$APPLY" = "1" ]; then
if ! rm -rf "$path"; then
# `$RM` 只是为了**让自检能喂一个"删不动"的失败**(见 --self-check 那一段):
# 默认就是 `rm`,生产行为逐字不变。不用 PATH shim 是因为 `in_use` 会把
# 命令行里含该路径的进程判成"在用",而自检必须把路径写在命令行上。
if ! "$RM" -rf "$path"; then
printf '
[FAIL] 删不掉:%s(权限/只读挂载/immutable?)\n' "$path" >&2
printf ' 已停在中途 —— 后面的判断没跑,收尾汇总也不会报"已删除"。\n' >&2
# ⚠️ 这句**不要**把收尾汇总的原话("已删除 N 项…")抄进来:
# 抄进来之后 `grep -c '已删除'` 会命中**这句报错自己**,
# 于是"有没有虚报成功"这个检查把自己的措辞当成了证据
# (与"`grep -c 用例名` 数出假数"是同一族:判据锚在了元文本上)。
printf ' 已停在中途 —— 后面的判断没跑,收尾汇总也不会执行。\n' >&2
exit 2
fi
# 删完还要**抽验**:`rm -rf` 对某些情况会"成功"却留东西(例如非空目录里
@ -140,11 +149,32 @@ if [ "${1:-}" = "--self-check" ]; then
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"
: > "$t/tmp/agentmail-gateway-build-2026010$i-000000"
# ★ 必须是**有内容的目录**,不能是 `: >` 造的空文件!
# 真实的构建暂存是 `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:-}"
# ★ 自检只允许在**自己刚建的临时假根**上跑。这条闸门是事后补的,因为它真出过事:
@ -204,6 +234,39 @@ if [ "${1:-}" = "--self-check" ]; then
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` 覆盖点。
# (不用 PATH shim:`in_use` 会把命令行里含该路径的进程判成"在用",
# 而自检必须把路径写在命令行上。)
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"
@ -310,7 +373,16 @@ fi
# 窗口留 1 份而不是 0:正常路径下部署脚本自己会收(见 redeploy-gateway.sh 末尾),
# 留下的只可能是**失败**的那次;失败时那一份正好用来事后查,删了就没现场了。
echo "==> $TMPD 的构建暂存(保留最新 $KEEP_BUILD_STAGES 份)"
mapfile -t stages < <(ls -1t "$TMPD"/agentmail-gateway-build-* 2>/dev/null)
# ★ `-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 到一半 —— 与快照同理,用过的路径一律先问一句。

View File

@ -201,13 +201,20 @@ bash deploy/prune-deploy-artifacts.sh --self-check # 判据自检(16 项,
`set -e` ⇒ **脚本当场中止**:只报第一个分叉文件,后续对象与收尾汇总都不打印。
退出码**恰好还是 1**(判定是对的),所以光量退出码看不见它 ——
这正是"判定对、证据被截断"。修法 `|| true`,并在注释里写明它不是风格而是判据。
13. **锚点必须与结论一一对应 —— 连"文件名"都会骗你。** 实例(2026-09-14):
我写了个探针判"通知脚本内容与仓库不一致 ⇒ 必须红",探针按**文件名**取文件
(`p.endsWith('service-failure-notify.mjs')`),可两个路径的 basename **相同**
(`deploy/service-failure-notify.mjs` vs `/opt/agentmail/bin/service-failure-notify.mjs`)
⇒ 两侧读到同一个串、探针**自己没分辨力**,样本"通过"得毫无意义。
与 `grep -c 用例名` 那个假数、与"名字出现在 `# Subtest:` 头"是同一族但不同形:
**锚在"名字"而没有锚在"哪一侧"**。
13. **自检样本必须"独立":每条样本都要说清它锚在哪一条检查、用的是哪一棵树。**
这一族我 2026-09-14 一天里踩了三次,合起来记比拆成三条好,因为**修法是同一个**
(显式命名 + 显式复位)—— 三者都是"断言的语义被当前状态悄悄改掉":
| 形态 | 实例 |
|---|---|
| **位置选择器** | 自检里 `bad[0]` / `badBak[0]`:前面插一条新检查之后,锚点指的就不是原来那条了 |
| **共享夹具状态泄漏** | 新补的四条豁免样本第一版**全红**:它们继承了两棵树里既有的改动(`extra.mjs`、只存在于 `a` 的 `test/`),红的理由根本不是要测的那件事 |
| **探针自身没有分辨力** | E 那次的探针按**文件名**判两侧,而两条路径 basename 相同(`deploy/service-failure-notify.mjs` vs `/opt/agentmail/bin/…`)⇒ 两侧读到同一个串、样本"通过"得毫无意义。与 `grep -c 用例名` 那个假数、"名字出现在 `# Subtest:` 头"同族不同形:**锚在"名字"而没有锚在"哪一侧"** |
配套一条(同一天踩到):**自检的夹具形状必须与生产形状一致**,否则夹具会把真 bug 藏住 ——
`prune-deploy-artifacts.sh` 的构建暂存自检用 `: >` 造**普通文件**,而生产是
**有内容的目录**;正是这个差异让"`ls -1t` 对多个目录打 `路径:` 头 ⇒ 那段清理一直空转"
这个 bug 藏了很久(判据自己在用文件名判"删了没有",夹具认了错形状,于是它也认了)。
14. **退出码也有量纲。** `node deploy/check-deploy-drift.mjs --self-check` 的退出码
**不是**"自检的结论":自检本体 28/28 全过,但同一个进程接着跑了宿主判据、
于是整体 exit 1。报"自检失败"就是把两个量纲混成一个。