diff --git a/client/electron/test/criteria-hygiene.test.mjs b/client/electron/test/criteria-hygiene.test.mjs index 50ba4f4..33fdc8b 100644 --- a/client/electron/test/criteria-hygiene.test.mjs +++ b/client/electron/test/criteria-hygiene.test.mjs @@ -472,32 +472,72 @@ test('★ AGC 真身从未进过远端历史(泄露之后响的闹钟,不是 /* * 对**每一条**远端 ref 的 tip 查该路径(`git log -- <路径>` = 从该 sha 可达的历史)。 * **本地没有那个对象就抓那一条**(精确,见上),抓完再查。抓不到 ⇒ unknown(红),不当绿。 + * + * ★★ 这是一条**会写仓库的判据**,所以写操作必须按仓库已有的那套纪律来 + * (pi 2026-09-15 抓到两处,部署锁 / `$RM` 随机名都有先例): + * + * ① **探针 ref 名要唯一** —— 本工作树**有多个会话**并发跑套件。原来的名字 + * `refs/agentmail-probe/` 是确定的 ⇒ 两个并发进程抓进**同一个** ref, + * 然后各自跑那圈**对全部远端 ref 的** `update-ref -d` ⇒ + * **一边能把另一边正在用的探针 ref 删掉**。 + * (后果我核过,**不是错判**:判定用的是 sha,对象抓进来不会因为 ref 被删而消失, + * `git log ` 照样成立 —— 是**垃圾/卫生**问题,不是"会报错绿"的问题。 + * 但这仍然是"我的判据去动别人的东西",不该留。) + * ⇒ 加 `-${process.pid}`。 + * + * ② **清理必须在 `finally` 里** —— `fetch` 是**网络 I/O**,最可能卡住/被超时杀掉; + * 而**任何在"抓到了"与"清理了"之间发生的打断**(Ctrl-C、runner 超时、kill) + * 都会把 `refs/agentmail-probe/*` **永久留在共享仓**(吊住对象、出现在 + * `for-each-ref` 类检查里)。原来清理在循环**之后**,不在 `finally` ⇒ 打断就留垃圾。 + * + * ③ 清理要按**试过的每一条**来,不能按"抓成功的那几条" —— + * `fetch` 可能**部分成功后再失败**(ref 建了、对象没齐),那一支也要删。 */ - const probeRef = ref => `refs/agentmail-probe/${ref.replace(/^refs\//, '').replace(/\//g, '-')}`; + const PROBE_TAG = `-${process.pid}`; + const probeRef = ref => + `refs/agentmail-probe/${ref.replace(/^refs\//, '').replace(/\//g, '-')}${PROBE_TAG}`; const published = []; const unresolved = []; const fetched = []; - for (const { ref, sha } of remoteRefs) { - let lg = git(['log', '--oneline', sha, '--', AGC_PATH]); - if (lg.status !== 0) { - // 本地缺这个对象 ⇒ 精确抓这一条(--no-tags 免得顺带拉别的 tag) - const dst = probeRef(ref); - const f = git(['fetch', '--no-tags', 'origin', `+${ref}:${dst}`]); - lg = git(['log', '--oneline', sha, '--', AGC_PATH]); - if (f.status !== 0 || lg.status !== 0) { - unresolved.push(`${ref}(${sha.slice(0, 8)})` - + (f.status !== 0 ? `\n fetch 也失败:${(f.stderr || '').trim().split('\n')[0].slice(0, 90)}` : '')); - continue; + const triedProbes = new Set(); + try { + for (const { ref, sha } of remoteRefs) { + let lg = git(['log', '--oneline', sha, '--', AGC_PATH]); + if (lg.status !== 0) { + // 本地缺这个对象 ⇒ 精确抓这一条(--no-tags 免得顺带拉别的 tag) + const dst = probeRef(ref); + triedProbes.add(dst); + const f = git(['fetch', '--no-tags', 'origin', `+${ref}:${dst}`]); + lg = git(['log', '--oneline', sha, '--', AGC_PATH]); + if (f.status !== 0 || lg.status !== 0) { + unresolved.push(`${ref}(${sha.slice(0, 8)})` + + (f.status !== 0 ? `\n fetch 也失败:${(f.stderr || '').trim().split('\n')[0].slice(0, 90)}` : '')); + continue; + } + fetched.push(`${ref}(抓到 ${dst},本次判定后删除)`); + } + if (lg.stdout.trim() !== '') { + published.push(`[${ref}] ${sha.slice(0, 8)}\n` + + lg.stdout.trim().split('\n').map(l => ' ' + l).join('\n')); } - fetched.push(`${ref}(抓到 ${dst},本次判定后删除)`); - } - if (lg.stdout.trim() !== '') { - published.push(`[${ref}] ${sha.slice(0, 8)}\n` - + lg.stdout.trim().split('\n').map(l => ' ' + l).join('\n')); } + } finally { + // 探针 ref 只为本条判据存在 —— **无论怎么离开(含被打断前的正常异常路径)都删** + for (const dst of triedProbes) git(['update-ref', '-d', dst]); + } + + /* + * ★★ 披露必须在**断言之前**打(我自己撞到的):原来这段在两条 `assert` **之后**, + * 于是**判红时它不执行** —— 而"判红"恰恰是读者最需要知道"这条判据刚才动过仓库"的时刻。 + * ⇒ **披露只在平安无事时发生**,等于没披露。和"读数器替一件事作证"同族: + * 这次是"**平安路径专有的披露**"。 + * 顺带更正我上一封的说法:我说过"抓过对象时必须说出来" —— + * 代码实际做到的是"**没出事的时候**说出来"。**说法与实现不一致,以实现为准。** + */ + if (fetched.length > 0) { + console.log(` (本条本次临时抓了 ${fetched.length} 条远端 ref 的对象:` + + fetched.map(s => s.split('(')[0]).join('、') + ' —— 这就是"这条判据会写仓库"的样子)'); } - // 探针 ref 只为本条判据存在 —— 用完即删,不给仓库留垃圾(也不影响上面已收集的结论) - for (const { ref } of remoteRefs) git(['update-ref', '-d', probeRef(ref)]); assert.deepEqual(unresolved, [], '这几条远端 ref 的 tip **拿不到、也抓不回来** ——\n' @@ -520,12 +560,4 @@ test('★ AGC 真身从未进过远端历史(泄露之后响的闹钟,不是 + ' 2) 历史清理(filter-repo)+ 强推,并与所有 clone 过的人对齐;\n' + ' 3) 查清是哪次 push 绕过了 `.githooks/pre-push`(`--no-verify`?没装的机器?)。\n' + ` 命中:\n ${published.join('\n ')}`); - if (fetched.length > 0) { - /* - * 本次为了判定**临时抓过对象**,必须说出来:这是一条判据在对仓库做**写操作**, - * 读者有权知道"绿是在什么代价/什么前提下拿到的"。不说 = 让判据偷偷改仓库。 - */ - console.log(` (本条本次临时抓了 ${fetched.length} 条远端 ref 的对象:` - + fetched.map(s => s.split('(')[0]).join('、') + ')'); - } }); diff --git a/deploy/install.sh b/deploy/install.sh index a139f2b..f3fe7cc 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -125,7 +125,35 @@ fi # 相位(pi 2026-09-14 裁定):部署门禁问的是"**别人已经产出的产物**能不能装到这台机器上", # 不是"产物是不是从当前源码新鲜构建的"。后者属于构建相位(`packaging`/`build-stamp`), # 挂在这里会**恒红**(部署路径不重新打包)—— 门挂错了相位。所以这里显式声明相位。 -( cd "$REPO/client/electron" && npm run typecheck && AGENTMAIL_CRITERIA_PHASE=install npm test && npm run build ) +# +# ★★ `--check` 模式下**不因这一门失败就中止**(由 pi 2026-09-15 的发现引出): +# 本脚本是 `set -e` 的,而这一行是**第一个会红的门禁** ⇒ 它一红,脚本立刻退出, +# **后面所有诊断一行都不打**。实测(探针插在下面 264 行那块之前,命中 **0 次**): +# +# $ bash deploy/install.sh --check # 套件红(3 条不是我的) +# …(判据汇总打完就结束)——**264 行之后整块从未执行** +# +# 受害者不只是我新加的那段,**包括既有的版本库钩子检查** —— +# 即 `install.sh --check` 的 `[ OK ] git 钩子已接` / `[WARN] git 钩子没接` +# **只在判据全绿时才会出现**。⇒ **一个只在"没出问题"时才说话的诊断**, +# 与"读数器替一件事作证"同族。 +# (pi 上一轮核过 `--check` 确实会报钩子 —— 那次套件是绿的,报的是真的; +# 但**这个机制比它看起来脆**:它依赖"门禁恰好全过"。) +# +# 修法:`--check` 下把这一门的失败**记下来**、继续跑完所有诊断, +# 最后用记下的码退出(见文件末尾的 `CHECK_GATE_RC`)。 +# **真装路径行为完全不变**(仍 `set -e` 立刻中止 —— 装机器时不该带着红往下走)。 +if [[ $CHECK_ONLY -eq 1 ]]; then + npm_rc=0 + ( cd "$REPO/client/electron" && npm run typecheck && AGENTMAIL_CRITERIA_PHASE=install npm test && npm run build ) \ + || npm_rc=$? + if [[ $npm_rc -ne 0 ]]; then + echo " [FAIL] 前端门禁(typecheck / 判据 / build)退出码 ${npm_rc} —— 详见上面的红" >&2 + CHECK_GATE_RC="$npm_rc" + fi +else + ( cd "$REPO/client/electron" && npm run typecheck && AGENTMAIL_CRITERIA_PHASE=install npm test && npm run build ) +fi # ── 安装相位真正能判的那一半:**读产物自证**(不是重算 dist)── # 同一个不变量("这份产物是谁")在两个相位要两种判据:构建相位重新计算,安装相位读它自己说的。 @@ -278,6 +306,42 @@ if [[ $CHECK_ONLY -eq 1 ]]; then echo " 接上:./deploy/install.sh --git-hooks(只写本地 git 配置,不需要 root)" fi + # ── `origin` URL:**打印出来**(不判具体值)──────────────────────────────── + # + # ★ 为什么只报不判(pi 2026-09-15):`.git/config` 是**共享、无版本控制**的状态, + # 而 `origin` 的 **URL 字符串没有可对的外部真值** —— 外部只能证明它的**效果** + # (能到达同一个仓、tip 相同),**证明不了它的值**。 + # 我自己就栽过:把 `origin` 改坏后"对着远端真值恢复",`ls-remote` 只能告诉我 + # "这个 URL 能到那个仓",于是我把 `http://192.168.2.106:3000/...`(原来的值) + # 换成了 `https://gitea.jianfgit.xyz/...` —— **同仓、不同值**,而我还说"已恢复"。 + # ⇒ "对着远端真值恢复 URL"是个**类别错误**(这句是 pi 的原话,我认)。 + # + # ★ 所以这里做两件**能做**的事: + # 1) 把**当前值打出来** —— 下次再有人改它,至少有一处"改前/改后"能被看见; + # 2) **判它可达**(`ls-remote` 能拿到 refs)—— 这才是真有外部真值的那部分性质。 + # 不可达时判 WARN(不是 FAIL):局域网 URL 在离线/换网时本来就不可达, + # 而 `--check` 不该因为网络问题红。 + # + # ⚠️ 我**没有**断言它等于某个常量:本仓并没有登记"规范 URL", + # 而编一个常量去比对 = **猜数字的判据**(真值无处可得,写死就成了我的记忆)。 + # 要强判,先把规范值登记进版本库(`docs/` 或本文件顶部),再断言。 + origin_url="$(git -C "$REPO" remote get-url origin 2>/dev/null || true)" + if [[ -z "$origin_url" ]]; then + echo " [WARN] 远端 \`origin\` **没有配** —— 推送/取远端都无从谈起" + else + echo " [ OK ] 远端 origin = ${origin_url}(下面是*效果*检查,值本身无外部真值可比)" + # ★ 必须**限时**:实测把 origin 指向一个不可达地址(`http://192.0.2.1:3999/…`)时, + # `git ls-remote` **挂住不动**(60s 都没返回)—— 而"干跑卡死"比"干跑报 WARN"坏得多 + # (卡死的门会被当成"这脚本跑不了"而整个跳过)。所以给 15s 上限, + # 超时 = 不可达 = WARN(本脚本对网络问题的既定态度:WARN,不 FAIL)。 + if timeout 15 git -C "$REPO" ls-remote --exit-code origin >/dev/null 2>&1; then + echo " [ OK ] origin 可达(\`git ls-remote origin\` 能拿到 refs)" + else + echo " [WARN] origin **不可达**(或 15s 内没应答)—— 本机的推送/取远端会失败" + echo " 注意:这条只说明'到不了',不说明'地址写错了';两者要分开看。" + fi + fi + # ★ `go vet`/`go test` 提到这个相位来跑(pi 评审 2026-09-14)。 # # 原先的措辞是"**所有会红的门禁都跑过了**(前端 typecheck/test/build、共用模块同源、 @@ -287,6 +351,14 @@ if [[ $CHECK_ONLY -eq 1 ]]; then # 而"第一个拿到 root 的人第一次跑门禁"正是 `--check` 要解决的场景。 # 它们不写系统目录(只写 go 缓存),所以放进这个相位没有副作用。 echo "==> 校验 Gateway 源码(go vet + go test)" + # ★★ 先把"前面哪道门红了"传出去,**再**跑 go vet —— 位置是实测定下来的,不是随手放的: + # 我第一版把这段放在文件末尾,实测 go vet 因 go 缓存权限红 ⇒ `set -e` 直接中止 + # ⇒ **那段话永远走不到**。放在 go vet **之前**,无论后面谁红它都报得出来。 + # (**一个只在"一切顺利"时才报的总结 = 只在没出问题时才说话的诊断**,同一个病。) + if [[ -n "${CHECK_GATE_RC:-}" ]]; then + echo " [FAIL] 前端门禁没过(退出码 ${CHECK_GATE_RC})—— 上面标了 [FAIL] 的那一处就是" >&2 + echo " ⇒ 但**诊断已经全部跑完**(本次改动要的正是这个:别让第一道红吃掉后面的诊断)。" >&2 + fi # 环境不再在这里逐个预检 —— 开头 source 的 env-defaults.sh 已经保证 # HOME/TMPDIR/GOMODCACHE/PATH 齐备(那道预检是"第四次"的补丁,已被一处取代)。 if ! ( cd "$REPO/server" && go vet ./... && go test ./... ); then