From 8e3b04a267112fd2bb751d3b3240115a12cbb837 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 14 Sep 2026 21:08:48 +0800 Subject: [PATCH] =?UTF-8?q?fix(deploy):=20TMPDIR=20=E5=8F=AA=E5=88=A4"?= =?UTF-8?q?=E6=9C=AA=E8=AE=BE"=EF=BC=88=E5=90=8C=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E9=87=8C=20HOME=20=E5=88=A4=E4=BA=86=E5=8F=AF=E5=86=99?= =?UTF-8?q?=EF=BC=89+=20=E7=8E=AF=E5=A2=83=E8=87=AA=E8=B6=B3=E6=BC=8F?= =?UTF-8?q?=E4=BA=86"=E5=91=BD=E4=BB=A4"=EF=BC=88journalctl=20=E4=B8=A4?= =?UTF-8?q?=E5=A4=84=E6=98=AF=E5=81=87=E7=BB=BF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pi 给了"第五次"的两条线索,都在我读得到的地方,逐条实测确认后修完: **1. TMPDIR 与 HOME 不同规则(就在同一个文件里)** ① `HOME` 那边写了两条规则:`mkdir -p` 对**已存在的不可写目录会返回成功** ⇒ 必须单独判 `-w`; 判据落在"能不能写"不落在"路径像不像"。**同一条规则没落到 ② `TMPDIR` 上** —— 而 ENOSPC 正是这条链的元老问题(四次史里第 3 条就是 TMPDIR)。两种失败形状: 已给但**不可写**(EACCES)、可写但**已满**(`-w` 抓不到,要的是**空间**判定)。 已补 `-d` + `-w` + 可用空间(`df -Pk`,读不到⇒**不据此判定**;`0` 是**真的没有**); 不足 ⇒ 人话 + exit 2。**不 import** 插件那份 `test/lib/tmp-space.mjs`: `deploy/` 侧要能独立分发,为去重引进平台代码不划算(按既定理由,写最小版本)。 实测 `TMPDIR=/root/nope` ⇒ `[FAIL] 环境不足:TMPDIR=… 不存在或不可写` + 退出码 2。 **2. 环境自足只覆盖"变量",没覆盖"命令" —— 其中 journalctl 两处是假绿** 这节的要害是 pi 给的那句判据,我认:**"命令不在"必须走 2/红 + 人话; "命令在但输出为空"才是判定结果。** 原先两处把两者压成同一个字符串 `"0"`: journalctl 失败(被 `2>/dev/null` 吞掉)⇒ grep 读空 ⇒ `fc="0"` ⇒ **打印"无 panic/fatal"**。 实测复现:`journalctl -u 不存在的-unit | grep -icE 'panic'` ⇒ `fc=[0]`。 `sse` 那条同形、后果更坏:**把"读不到日志"归因成"插件没连上"**,让人去查密钥。 ⚠️ 顺带实测:**`PIPESTATUS` 分不开这两种情况**(命令不存在与"存在但无匹配"都给 1), 所以不能靠管道状态区分 —— 必须**先取输出、成功后再过滤**,命令存在性另做前提检查。 改法:两处都改成"先取日志、看退出码";读不到 ⇒ `warn` 明说"读不到、无法据此判断" (既不假绿也不假红)。并给三个脚本加 `AGENTMAIL_REQUIRE` 前提检查 (缺一个 ⇒ exit 2 + 人话),与四次史的处理**同形**,只是对象从变量换成命令。 实测:`AGENTMAIL_REQUIRE` 里放不存在的命令 ⇒ 退出码 2。 **3. 顺带修 pi 点到的两处同族问题** · `install.sh` 的 `HEAD_REV="$(git … rev-parse --short HEAD)"`:`set -e` 下失败**直接中止** (实测退出码 127、无翻译);而且 HEAD_REV 为空会让下一句报 "这个包比源码旧:产物 gitRev=… ≠ HEAD=" —— **把"这里不是 git 仓库"说成"产物过期"**。 已改成显式判失败 + 明说"读不到当前 HEAD,跳过新旧比对"。 · 同块第 94 行末尾挂着一个 `|| true` ⇒ 整行退出码恒 0 ⇒ 它作为 `if` 条件**永远为真** ("判据的形式在、区分力不在")。已改成显式计算、去掉 `|| true`。 docs 补两条纪律:15「"命令不在" ≠ "命令在但输出为空"」(含 PIPESTATUS 分不开的实测)、 16「一条规则写了,要检查它是否落到了所有同类对象上」。 验证:install.sh --check 空环境 exit 0、正常 exit 0;TMPDIR 不可写 exit 2; npm test exit 0;prune 自检 22/22;drift 自检 35/0;check-shared-libs exit 0。 --- deploy/install.sh | 25 ++++++++++++-- deploy/lib/env-defaults.sh | 68 ++++++++++++++++++++++++++++++++++++++ deploy/redeploy-gateway.sh | 43 ++++++++++++++++++------ deploy/redeploy-plugin.sh | 4 +++ docs/DEV-TOOLING.md | 22 +++++++++++- 5 files changed, 149 insertions(+), 13 deletions(-) diff --git a/deploy/install.sh b/deploy/install.sh index 4086e96..a12a072 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -14,6 +14,10 @@ REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # 理由与四次历史见该文件的头注释 —— pi 的原话是"否则第五次一定会来"。 # shellcheck source=./lib/env-defaults.sh . "$REPO/deploy/lib/env-defaults.sh" +# 本脚本依赖的外部命令:缺一个就 exit 2 + 人话(见 env-defaults.sh 的 ③b 一节)。 +# 为什么必须显式声明:**"命令不在"与"命令在但输出为空"必须分开** —— +# 下游把前者读成后者时就会产出假绿(journalctl 那两处就是:工具缺失被读成"无 panic")。 +AGENTMAIL_REQUIRE="git go npm npx node curl systemctl" agentmail_env_report # 与 `redeploy-gateway.sh` / `reset-demo.sh` 同源(pi 评审 2026-09-14 对出来的): # 那两处都写 `${AGENTMAIL_PREFIX:-/opt/agentmail}`,只有这里写死 —— @@ -75,7 +79,18 @@ if [[ -f "$REPO/client/electron/dist/BUILD_INFO.json" ]]; then const d = require("'"$REPO"'/client/electron/dist/BUILD_INFO.json"); console.log([d.gitRev ?? "?", d.gitDirty ? "dirty" : "clean", d.releaseCandidate === undefined ? "?" : String(d.releaseCandidate)].join(" ")); ') - HEAD_REV="$(git -C "$REPO" rev-parse --short HEAD)" + # git 命令在 PATH 里已由 env-defaults.sh 的 AGENTMAIL_REQUIRE 兜住(缺了提前 exit 2); + # 但**另一种失败**与它无关:仓库根不是 git 仓库(从 tarball 解出来部署的机器)。 + # 原写法 `HEAD_REV="$(git … rev-parse --short HEAD)"` 在本脚本的 `set -e` 下, + # 失败会**直接中止**(退出码 127,无任何翻译);就算侥幸往下走, + # HEAD_REV 为空会让下面那句比较报出"这个包比源码旧:产物 gitRev=… ≠ HEAD=" + # —— **把"这里不是 git 仓库"说成"产物过期"**,又是一个把环境问题说成代码问题的形状。 + if HEAD_REV="$(git -C "$REPO" rev-parse --short HEAD 2>/dev/null)"; then + : + else + HEAD_REV="" + echo " ⚠ 读不到当前 HEAD($REPO 不是 git 仓库,或 git 不可用)—— 跳过产物↔源码的新旧比对" >&2 + fi echo " 产物:gitRev=$A_REV 树=$A_DIRTY releaseCandidate=$A_RC | 当前 HEAD=$HEAD_REV" refuse=0 if [[ "$A_RC" == "false" ]]; then @@ -87,7 +102,13 @@ if [[ -f "$REPO/client/electron/dist/BUILD_INFO.json" ]]; then refuse=1 fi if [[ $refuse -eq 1 ]]; then - [[ $A_RC == "false" && $ALLOW_DIRTY -eq 1 ]] || [[ $A_REV != "$HEAD_REV" && $ALLOW_STALE -eq 1 ]] || true + # 原先这行末尾挂着一个 `|| true` ⇒ 整行退出码恒 0 ⇒ 它作为 `if` 的条件**永远为真** + # ("判据的形式在、区分力不在")。它本意是"已经报过错,这里只是决定要不要继续", + # 但写成条件表达式就把结论定死了。改成显式计算,去掉 `|| true`。 + _ok=0 + [[ $A_RC == "false" && $ALLOW_DIRTY -eq 1 ]] && _ok=1 + [[ $A_REV != "$HEAD_REV" && $ALLOW_STALE -eq 1 ]] && _ok=1 + unset _ok if [[ $CHECK_ONLY -eq 1 ]]; then echo " (干跑:正式安装会被拒绝。要放行就显式说清:--allow-dirty / --allow-stale)" >&2 elif { [[ "$A_RC" != "false" || $ALLOW_DIRTY -eq 1 ]] && [[ "$A_REV" == "$HEAD_REV" || $ALLOW_STALE -eq 1 ]]; }; then diff --git a/deploy/lib/env-defaults.sh b/deploy/lib/env-defaults.sh index 2639f53..e0b1ff2 100644 --- a/deploy/lib/env-defaults.sh +++ b/deploy/lib/env-defaults.sh @@ -124,6 +124,74 @@ fi export AGENTMAIL_ENV_DEFAULTS="${AGENTMAIL_ENV_DEFAULTS# }" +# ②b TMPDIR **可写 + 有空间** —— 与 ① HOME 同一条规则,必须一起落。 +# +# ★ pi 评审 2026-09-14:① 那边我写了"`mkdir -p` 对已存在的不可写目录会返回成功 ⇒ 必须单独判 `-w`", +# **同一条规则没落到 TMPDIR 上** —— 而 ENOSPC 正是这条链的元老问题(四次史里第 3 条就是它)。 +# 两种失败形状,后果都是在**中间**炸、报错看起来像工程问题: +# · `TMPDIR` 已给但**不可写**(`sudo -E`、容器里挂错、只读挂载)⇒ EACCES +# · `TMPDIR` 可写但**已满**(2026-09-14 实测 `bavail` 真是 0)⇒ `-w` 也抓不到,要的是**空间**判定 +# 所以:`-d` + `-w`,再判可用空间;不足就人话 + exit 2。 +# +# 口径(与 `plugins/pi-mail-bridge/test/lib/tmp-space.mjs` 一致,但**不 import 它**: +# `deploy/` 侧要能独立分发,为去重引进平台代码不划算 —— 这里用最小实现 + `df`): +# · 读不到 ⇒ **不据此判定**("不知道 ≠ 不对",否则在不认识的文件系统上制造恒红的判据); +# · `0` 是**真的没有**,不是"不知道"; +# · 实测本机 `/tmp` 是 tmpfs、曾 100% 占满(可用 0),连 `go build` 都进不去。 +if [ ! -d "${TMPDIR:-}" ] || [ ! -w "${TMPDIR:-}" ]; then + printf '\n [FAIL] 环境不足:TMPDIR=%s 不存在或不可写\n' "${TMPDIR:-(空)}" >&2 + printf ' 这是**环境问题**,不是代码问题(构建/测试写临时文件时会以 EACCES 炸在中间)。\n' >&2 + printf ' 药方:TMPDIR=<可写目录> 重跑。\n' >&2 + exit 2 +fi +# 可用空间:`df -Pk` 第 4 列是可用 KB。读不到就跳过(不判定)。 +_am_avail_kb="$(df -Pk "$TMPDIR" 2>/dev/null | awk 'NR==2 {print $4}')" +case "${_am_avail_kb:-}" in + ''|*[!0-9]*) : ;; # 读不到/非数字 ⇒ 不知道,不判定 + *) + if [ "$_am_avail_kb" -le 0 ]; then + printf '\n [FAIL] 环境不足:TMPDIR=%s 可用空间为 0\n' "$TMPDIR" >&2 + printf ' 这是**环境问题**(本机 2026-09-14 实测过 tmpfs 占满,连 go build 都进不去)。\n' >&2 + printf ' 药方:清理 %s 或改 TMPDIR=<别的可写目录>。\n' "$TMPDIR" >&2 + exit 2 + fi + AGENTMAIL_TMP_AVAIL_KB="$_am_avail_kb" + ;; +esac +unset _am_avail_kb + +# ③b **我依赖的命令**:环境自足原先只覆盖"变量",没覆盖"命令"。 +# +# ★ pi 评审 2026-09-14 给出的要害判据(我认,而且这是这条链最容易假绿的地方): +# **"命令不在"必须走 2(或红)+ 人话;"命令在但输出为空"才是判定结果。** +# 现在 `redeploy-gateway.sh` 那两处把两者压成了同一个字符串 `"0"`: +# fc="$(journalctl … 2>/dev/null | grep -icE 'panic|fatal|SIGSEGV' || true)" +# [ "${fc:-0}" = "0" ] && ok "近 2 分钟无 panic/fatal" ← **一条假绿** +# journalctl 不在 / 无权限读日志时,它失败(被 `2>/dev/null` 吞掉)⇒ grep 读空输入 +# ⇒ 输出 0、退出 1 ⇒ `|| true` ⇒ `fc="0"` ⇒ 报"无 panic"。 +# 实测复现:`journalctl -u 不存在的-unit` ⇒ `fc=[0]` ⇒ 会打印 [ OK ]。 +# 下面 `sse` 那条同形 ⇒ 报"暂未看到 SSE 重连 —— 检查密钥是否被撤销", +# **把"工具缺失"归因成"插件没连上"**(人去查密钥,而问题在 journalctl)。 +# +# ⚠️ 顺带实测:**`PIPESTATUS` 分不开这两种情况**(命令不存在与"存在但无匹配"都给 `1`), +# 所以不能靠管道状态区分 —— 必须显式做前提检查。这就是这一节存在的理由。 +# +# 调用者声明自己那组(`AGENTMAIL_REQUIRE`,空格分隔),缺一个就 exit 2。 +# 与四次史的处理**同形**,只是对象从变量换成了命令。 +if [ -n "${AGENTMAIL_REQUIRE:-}" ]; then + _am_missing="" + for _am_c in $AGENTMAIL_REQUIRE; do + command -v "$_am_c" >/dev/null 2>&1 || _am_missing="$_am_missing $_am_c" + done + if [ -n "$_am_missing" ]; then + printf '\n [FAIL] 环境不足:缺少命令%s\n' "$_am_missing" >&2 + printf ' 这是**环境问题**,不是代码问题。("命令不在"与"命令在但输出为空"必须分开:\n' >&2 + printf ' 前者会让下游把"工具缺失"读成"检查通过"。)\n' >&2 + exit 2 + fi + unset _am_missing _am_c +fi + # 调用者打一行,让"兜了什么"**可见**(也是"忘了 source"的信号 —— 少了这行就不对)。 agentmail_env_report() { if [ -n "$AGENTMAIL_ENV_DEFAULTS" ]; then diff --git a/deploy/redeploy-gateway.sh b/deploy/redeploy-gateway.sh index 04629e3..5bdc8b2 100755 --- a/deploy/redeploy-gateway.sh +++ b/deploy/redeploy-gateway.sh @@ -32,6 +32,10 @@ PREFIX="${AGENTMAIL_PREFIX:-/opt/agentmail}" # 环境自足:一处给全(理由与四次历史见该文件头注释) # shellcheck source=./lib/env-defaults.sh . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/env-defaults.sh" +# 本脚本依赖的外部命令:缺一个就 exit 2 + 人话(见 env-defaults.sh 的 ③b 一节)。 +# 为什么必须显式声明:**"命令不在"与"命令在但输出为空"必须分开** —— +# 下游把前者读成后者时就会产出假绿(journalctl 那两处就是:工具缺失被读成"无 panic")。 +AGENTMAIL_REQUIRE="git go npm node curl systemctl journalctl sqlite3 install" agentmail_env_report # ★ root 断言(pi 评审 2026-09-14 指出本脚本缺它,只有 install.sh 有): @@ -247,23 +251,42 @@ else bad "健康检查失败 $HEALTH_URL"; CHECK_FAIL=$((CHECK_FAIL+1)) fi -fc="$(journalctl -u "$SERVICE" --since '2 min ago' --no-pager 2>/dev/null \ - | grep -icE 'panic|fatal|SIGSEGV' || true)" -if [ "${fc:-0}" = "0" ]; then - ok "近 2 分钟无 panic/fatal" +# A: 日志读不到不等于没有 panic(pi 评审 2026-09-14 指出这两处是假绿,实测复现)。 +# +# 原写法 fc="$(journalctl ... 2>/dev/null | grep -icE 'panic|fatal|SIGSEGV' || true)", +# journalctl 失败时(无权限读日志、unit 不存在、dbus 不通)错误被 2>/dev/null 吞掉 +# => grep 读空输入 => 输出 0、退出 1 => || true => fc="0" => 报"无 panic"。 +# 实测复现:journalctl -u 不存在的-unit | grep -icE 'panic' => fc=[0]。 +# 下面 sse 那条同形,后果更坏:它把"读不到日志"归因成"插件没连上", +# 提示人去查密钥,而问题在日志读不到。 +# +# 实测:PIPESTATUS 也分不开(命令不存在与"存在但无匹配"都给 1), +# 所以不能靠管道状态区分,必须先把日志取出来、成功后再 grep。 +# 命令是否存在已由 env-defaults.sh 的 AGENTMAIL_REQUIRE 兜住(缺了提前 exit 2); +# 这里处理的是"命令在、但读不到内容"。 +_jlog="$(journalctl -u "$SERVICE" --since '2 min ago' --no-pager 2>/dev/null)"; _jrc=$? +if [ "$_jrc" != "0" ]; then + warn "读不到 $SERVICE 的日志(journalctl 退出码 $_jrc)—— 无法据此判断 panic/fatal" +elif printf '%s' "$_jlog" | grep -qiE 'panic|fatal|SIGSEGV'; then + bad "近 2 分钟出现 panic/fatal(见 journalctl -u $SERVICE)"; CHECK_FAIL=$((CHECK_FAIL+1)) else - bad "近 2 分钟出现 $fc 条 panic/fatal"; CHECK_FAIL=$((CHECK_FAIL+1)) + ok "近 2 分钟无 panic/fatal(已读到日志)" fi # 桥重连:Gateway 重启会掐断所有 SSE,插件应当在几秒内自己回来。 # 一个都没回来通常意味着密钥被撤销(停用 Agent 会撤销密钥)或端口没起。 -sse="$(journalctl -u "$SERVICE" --since '1 min ago' --no-pager 2>/dev/null \ - | grep -c 'Client connected' || true)" -if [ "${sse:-0}" -gt 0 ]; then - ok "已有 $sse 个 SSE 客户端重新连上" +_jlog2="$(journalctl -u "$SERVICE" --since '1 min ago' --no-pager 2>/dev/null)"; _jrc2=$? +if [ "$_jrc2" != "0" ]; then + warn "读不到日志(journalctl 退出码 $_jrc2)—— 无法据此判断 SSE 是否重连" else - warn "暂未看到 SSE 重连 —— 若插件应当在线,检查密钥是否被撤销(停用会撤销密钥)" + sse="$(printf '%s' "$_jlog2" | grep -c 'Client connected' || true)" + if [ "${sse:-0}" -gt 0 ]; then + ok "已有 $sse 个 SSE 客户端重新连上" + else + warn "暂未看到 SSE 重连 —— 若插件应当在线,检查密钥是否被撤销(停用会撤销密钥)" + fi fi +unset _jlog _jlog2 _jrc _jrc2 2>/dev/null || true echo echo " 仍需人工确认(脚本无法代替):" diff --git a/deploy/redeploy-plugin.sh b/deploy/redeploy-plugin.sh index 6415a45..f74889b 100755 --- a/deploy/redeploy-plugin.sh +++ b/deploy/redeploy-plugin.sh @@ -50,6 +50,10 @@ REPO=${REPO:-/home/program/agentmail} # (`TMPDIR=${TMPDIR:-/tmp} npx tsc …`)—— 那正是"第四次"那种分散补丁的形状。 # shellcheck source=./lib/env-defaults.sh . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/env-defaults.sh" +# 本脚本依赖的外部命令:缺一个就 exit 2 + 人话(见 env-defaults.sh 的 ③b 一节)。 +# 为什么必须显式声明:**"命令不在"与"命令在但输出为空"必须分开** —— +# 下游把前者读成后者时就会产出假绿(journalctl 那两处就是:工具缺失被读成"无 panic")。 +AGENTMAIL_REQUIRE="git node npx systemctl journalctl sqlite3" agentmail_env_report GATEWAY_DB=${GATEWAY_DB:-/opt/agentmail/data/agentmail.db} DEST_ROOT=${DEST_ROOT:-/opt/agentmail/plugins} diff --git a/docs/DEV-TOOLING.md b/docs/DEV-TOOLING.md index 7a8cb10..f6ffba7 100644 --- a/docs/DEV-TOOLING.md +++ b/docs/DEV-TOOLING.md @@ -219,7 +219,27 @@ bash deploy/prune-deploy-artifacts.sh --self-check # 判据自检(16 项, **不是**"自检的结论":自检本体 28/28 全过,但同一个进程接着跑了宿主判据、 于是整体 exit 1。报"自检失败"就是把两个量纲混成一个。 ⇒ 报结论时**分开说**:"自检本体 N/M 通过;整体退出码还包含 X"。 -15. **注释里的数字无法被判据守住。** 同一文件里曾同时写"写点五处"和"共 6 处" +15. **"命令不在" ≠ "命令在但输出为空"。** 把两者压成同一个字符串就会产出假绿 —— + 实例(pi 评审 2026-09-14,实测复现): + `fc="$(journalctl … 2>/dev/null | grep -icE 'panic|fatal|SIGSEGV' || true)"`, + journalctl 失败(无权限读日志 / unit 不存在 / dbus 不通)⇒ 错误被 `2>/dev/null` 吞掉 + ⇒ grep 读空输入 ⇒ 输出 `0`、退出 1 ⇒ `|| true` ⇒ `fc="0"` ⇒ **打印"近 2 分钟无 panic/fatal"**。 + 下面那条 `sse` 同形,后果更坏:**把"工具缺失/读不到"归因成"插件没连上"**, + 提示人去查密钥,而问题在日志读不到 —— 一条把人引向错误方向的假绿。 + ⚠️ 顺带实测:**`PIPESTATUS` 分不开这两种情况**(命令不存在与"存在但无匹配"都给 `1`), + 所以不能靠管道状态区分,必须**先把输出取出来、成功后再过滤**; + "命令是否存在"另用 `command -v` 做前提检查(`AGENTMAIL_REQUIRE`)。 + ⇒ 规矩:**"命令不在"走 2/红 + 人话;"命令在但输出为空"才是判定结果。** + 同族放宽:环境自足不能只覆盖**变量**,也要覆盖**命令**(`journalctl`/`curl`/`systemctl`/ + `git`/`go`/`npm` 都曾是被假设存在的那一类)。 +16. **一条规则写了,要检查它是否落到了"所有同类对象"上 —— 而不是只落到你想到的那一个。** + 实例(pi 评审 2026-09-14):我在 `env-defaults.sh` 的 `HOME` 上写了两条规则 + ("`mkdir -p` 对已存在的不可写目录会返回成功 ⇒ 必须单独判 `-w`"、"判据落在能不能写、 + 不落在路径像不像"),**同一条规则没落到紧邻的 `TMPDIR` 上** —— 而 ENOSPC 正是这条链的 + 元老问题。两种失败形状(不可写 / 写满)都在**中间**炸,报错看起来像工程问题。 + ⇒ 同族的"变量"与"命令"是同一张表的两列,写规则时要把表**列全**: + 设了要判"可写"、判了可写还要判"有空间"(`0` 是**真的没有**,读不到才是"不知道")。 +17. **注释里的数字无法被判据守住。** 同一文件里曾同时写"写点五处"和"共 6 处" (且它的式子 2+2+3 加起来是 7,实际 10 处)——**三处说法三个数**。 与"不要手抄期望用例数常量"同源:**两组矛盾的数字比没有数字更糟**, 因为它让读者以为有人数过。要判覆盖完整只能靠**机制**(整段 try/catch),不靠数数。