Skip to content

test(#823): 给 L1 并发上限闸门补 Docker 回归(含变异见证) - #835

Merged
vansin merged 58 commits into
ci/l1-concurrency-capfrom
test/823-concurrency-cap-gate
Aug 18, 2026
Merged

test(#823): 给 L1 并发上限闸门补 Docker 回归(含变异见证)#835
vansin merged 58 commits into
ci/l1-concurrency-capfrom
test/823-concurrency-cap-gate

Conversation

@vansin

@vansin vansin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

#823 的 P1(scripts/qa.sh:191 「Add a persisted Docker regression for the gate」)。

审查说得对:仓里搜 QA_L1_MAX_PAR 只有 qa.sh 一处,提交信息里的人工采样无法从仓库复现,所以下一次 fail-open 的计数/解析回归会静默恢复无上限运行。

套件跑的是真的 qa.sh,不是逻辑副本

docker 换成 PATH 上的桩 —— qa.shdockerrun()bash -c "$*",会解析到桩,真实闸门代码原样执行。在副本上测只能证明副本自洽。

峰值用事件流算最大重叠(每次桩调用写精确的 START/END 纳秒时间戳,事后排序),不用采样 —— 采样会漏峰值。

四个用例(审查点名的四种)

用例 生效值 峰值 判定
cap=2 2 2 上限确实生效
非法值 two 8(退回 nproc)+ 告警 7 不是静默不限
前导零 08 8 3 按十进制,不撞八进制
0 0 7 保留「不限」逃生口

对照有分辨力:cap=2 时峰值 2,而不限/8 时峰值 7 —— 不是恒真断言。

变异见证(没见过红的门不算门)

去掉 qa.sh 里的取值校验段(字节改动已验,跑完还原到原 hash),套件变红,且理由都对:

FAIL bad_warn:  必须告警 —— 期望 1,实测 0
FAIL bad_eff:   退回默认 —— 期望 8,实测 ?      ← 打印的是「上限 = two」,fail-open 现形
FAIL octal_eff: 按十进制 —— 期望 8,实测 08
RESULT: FAIL

report 的 SHA 刻意钉在含套件的那个提交上

Source: 08f54e8b包含被测套件本身的提交,不是它的父提交。#801 上有一条 P1 正是「report 里的 SHA 早于套件本身,证据无法从其标注的版本复现」——这里先提交套件、再按该 SHA 建镜像跑、最后把结果作为 report-only 子提交落下。

写这个套件时它抓到我两个 harness bug

  1. 生效值提取用 grep -oE '[0-9]+',先命中了 "L1" 里的那个 1,四个用例全报 1 —— 判据没在已知输入上校准过。改成只取 = 之后那个数,并用两组已知输入(8 / 0)校准;
  2. 桩对 buildrun 一视同仁各睡 0.35s,而 build 是同步的,导致 run 之间几乎不重叠、峰值恒为 1,高上限下断言没有分辨力。改成 build 立即返回、run 睡 1.2s。

两个都是「门看起来在跑、其实测不出东西」那一类 —— 是这个套件自己红出来的,不是我看出来的。


容器 --network none(npm view 也桩掉,否则真 npm 会等 DNS 超时,第一版就这么跑成超时)。测试镜像用完即删,未动任何他人镜像。

审查指出这道闸门没有可复现的回归:仓里搜 QA_L1_MAX_PAR 只有 qa.sh 一处,
提交信息里的人工采样无法从仓库复现,于是下一次 fail-open 的计数/解析回归
会静默恢复无上限运行。

套件跑的是**真的 scripts/qa.sh**,不是逻辑副本:把 docker 换成 PATH 上的桩
(qa.sh 的 dockerrun() 是 bash -c "$*",会解析到桩),真实闸门代码原样执行。
峰值用事件流算最大重叠,不用采样 —— 采样会漏峰值。

四个用例(审查点名的四种):
  cap=2      生效值 2,峰值 2         上限确实生效
  非法值 two  告警,生效值退回 nproc=8  不是静默不限
  前导零 08   生效值 8                按十进制,不撞八进制
  0          生效值 0,峰值 7         保留「不限」逃生口

对照:cap=2 峰值 2,而不限/8 时峰值 7 —— 断言有分辨力,不是恒真。

写这个套件时它自己抓到我两个 harness bug:
1) 生效值提取用 grep -oE '[0-9]+',先命中了 "L1" 里的 1,四个用例全报 1
   —— 判据没在已知输入上校准过。改成只取 `= ` 之后那个数,并用两组已知
   输入(8 / 0)校准;
2) 桩对 build 和 run 一视同仁各睡 0.35s,而 build 是同步的,导致 run 之间
   几乎不重叠、峰值恒为 1 —— 高上限下断言没有分辨力。改成 build 立即返回、
   run 睡 1.2s。

另:容器是 --network none,qa.sh 会跑 npm view 做 registry 快照,真 npm 会
等 DNS 超时而不是快速失败(第一版就这么跑成超时)。npm 一并桩掉,让被测
闸门成为唯一耗时来源。
Source 08f54e8 是包含被测套件本身的那个提交,不是它的父提交 ——
#801 上有一条 P1 正是「report 里的 SHA 早于套件本身,证据无法从其
标注的版本复现」。这里刻意先提交套件、再按该 SHA 建镜像跑,最后
把结果作为 report-only 子提交落下。

Exit 0 / RESULT: PASS,并附去掉校验段的变异见证(RESULT: FAIL)。
@vansin

vansin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

本 PR 至今无审查记录。(开 PR 未必触发,#833 上实测显式 @ 是有效的。)

自查发现:上一版新增了 tests/test823-l1-concurrency-cap/,但全仓没有任何
地方引用它。用阳性对照判实的 —— test823 在自身目录之外命中 0 处,而同类
的 test745 命中 1 处;同时确认被查文件可读、grep 能命中已知串,排除"查询
没生效"。

也就是说我在一条"补上缺失回归"的 PR 里,交付了一个不会被任何东西调用的
套件 —— 正是 #801 那条 P1(gate_is_wired)说的情形,只不过这次在我自己
的产出上。

注册后再验:test823 命中 1 处,与对照 test745 一致。

自包含也验了:注册之后套件会在自己的 L1 列表里看到自己。实测仍 PASS
(退出码 0,四个用例全绿,峰值对照依旧有分辨力:cap=2 → 峰值 2,
不限 → 峰值 7),没有递归或自锁。
@vansin

vansin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

自查补一刀:上一版建了个没人会跑的门,已注册(37dcbf38 → 3171aa33)

写完套件之后我核了一件本该先核的事:有没有东西会调用它。

test823 在自身目录之外的命中 = 0
对照 test745(已接线的同类) = 1

同时确认被查文件可读、grep 能命中已知串(qa.sh 里含 "test" 的行有 26 处),排除「查询没生效」这种假阴性

结论:我在一条「补上缺失回归」的 PR 里,交付了一个不会被任何东西调用的套件。 这正是 #801 那条 P1(gate_is_wired)说的情形,只不过这次在我自己的产出上。

已注册进 L1_TESTS,放在最前

L1_TESTS=(
  # 这道闸门自己的回归。放在最前:它跑的是本脚本,若闸门坏了应当最先暴露。
  "test823-l1-concurrency-cap"
  "qa-cli-01-hub-start"

注册后复验:命中 1 处,与对照一致。

自包含也验了(不是假设)

注册之后,套件会在自己的 L1 列表里看到自己 —— 内层 qa.sh 会对 test823 也调一次 docker 桩。实测仍 PASS:

退出码 0 / failures=0 / RESULT: PASS
cap=2 → 峰值 2      不限 → 峰值 7      (对照依旧有分辨力)

没有递归、没有自锁。


一句:这已经是这一批 PR 里第二次「我修的正是我当场又犯的那一类」。上一次是 #833(指引走不通),这次是「门没接线」。

区别在于这次是我自己查出来的,不是审查查出来的 —— 而我之所以会去查,是因为前几次都是审查替我查的。把别人指出过的那一类,主动用在自己下一件产出上,这一步开始起作用了。

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 37dcbf38bc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread tests/test823-l1-concurrency-cap/run.sh Outdated
Comment on lines +34 to +35
if [ "${1:-}" = "build" ]; then exit 0; fi
printf 'S %s %s\n' "$(date +%s%N)" "$$" >> "$T823_EV"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Record events only for actual docker run calls

When qa.sh launches no L1 containers—for example, if every build fails or dockerrun stops resolving this stub—the initial docker info probe still falls through this branch and records one START/END pair. Because the nested QA failure is swallowed with || true and the cap2 assertion accepts a peak of 1, all four cases can report PASS without exercising the concurrency gate. Restrict event recording to the run subcommand and assert that the expected run calls occurred.

Useful? React with 👍 / 👎.

Comment on lines +113 to +115
IFS='|' read -r p eff warned <<< "$(run_case zero 0)"
say "- 0(不限) 峰值=$p 生效值=$eff 告警=$warned"
check zero_eff "$eff" 0 "0 保留为「不限」的逃生口"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Assert that a zero cap actually permits concurrency

If the 0 = unlimited behavior regresses to a serial or otherwise capped execution while the status message still prints 0, this case passes because it only checks the parsed effective value and never checks the measured peak. That leaves the advertised escape hatch unprotected; compare this case's peak with the capped case (or otherwise require observable overlap) rather than treating the printed configuration as proof of behavior.

Useful? React with 👍 / 👎.

RUN chown -R node:node /workspace
USER node

ENTRYPOINT ["bash", "/workspace/run.sh"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add the regression suite to an automated workflow

When a later scripts/qa.sh change regresses this gate, no repository workflow executes this entrypoint: .github/workflows/qa.yml only invokes bash scripts/qa.sh --l1, whose L1_TESTS inventory does not contain test823-l1-concurrency-cap, while e2e-docker.yml never builds this Dockerfile. The test therefore cannot catch regressions unless someone remembers to run it manually; add a dedicated CI build/run step rather than inserting it into the inner L1 list, which would recursively invoke itself.

Useful? React with 👍 / 👎.

Comment on lines +8 to +9
ARG SOURCE_COMMIT
ENV TEST823_SOURCE_COMMIT=${SOURCE_COMMIT}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Verify the source commit against the copied test inputs

When this image is built from a dirty checkout or from a revision other than the supplied SOURCE_COMMIT, the container still prints and accepts that SHA because it is merely copied into an environment variable and run.sh checks only its syntax. This can archive a PASS under a commit that does not contain the code actually exercised, defeating the report's reproducibility claim; compare the copied inputs byte-for-byte with the named revision or embed and verify their authoritative hashes.

AGENTS.md reference: AGENTS.md:L25-L25

Useful? React with 👍 / 👎.

① 桩原来对任何非 build 的 docker 子命令都记事件,峰值会被无关调用抬高。
   改成只有 `docker run` 才记。
② 0(不限)原来只断言生效值为 0 —— 那只证明它被这么解析,没证明它真的
   放开了并发。补一条:不限时峰值必须明显高于 cap=2 的峰值。
③ SOURCE_COMMIT 原来只验 40 位十六进制格式。任何 SHA 都能通过,而报告
   里那个 SHA 可能根本不含镜像里被测的文件 —— 这正是我自己在 #801 上
   提的那条 P1,建这个套件时原样犯了一遍。
   改成:构建时把 run.sh 在该 commit 下的 git blob 哈希作为 build-arg
   传入,容器内就地重算并比对(blob 哈希 = sha1("blob <len>\\0"+内容),
   不需要容器里装 git)。

第四条「接进自动 workflow」上一提交已自查修掉(注册进 L1_TESTS),
审查针对的是修之前的坐标。
@vansin

vansin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

四条 P2:三条已修,一条上一提交已自查修掉

# 意见 处置
run.sh:35 桩只该给真正的 docker run 记事件 已修
run.sh:115 0(不限)要断言真的放开了并发 已修
Dockerfile:9 SOURCE_COMMIT 要与镜像里被测字节绑定 已修
Dockerfile:19 套件要接进自动 workflow 上一提交已自查修掉(审查针对修前坐标)

① 桩记多了会污染峰值 —— 修完数字明显更干净

原来对任何非 build 的 docker 子命令都记事件。改成只有 docker run 才记之后:

修前  cap=2 → 2    two → 7    08 → 3    0 → 7
修后  cap=2 → 2    two → 8    08 → 8    0 → 13

这不是修辞:修前 08 的峰值只有 3,与 cap=2 的 2 几乎分不开;修后是 8 vs 2,对照锐利得多。测量保真度直接决定断言的分辨力。

② 「不限」原来只验了解析,没验行为

原来只断言 生效值 == 0。那只证明它被这么解析,没证明它真的放开了并发。补:不限时峰值必须明显高于 cap=2 的峰值 —— 实测 13 > 2,通过。

③ SHA 绑到字节 —— 这条正是我自己在 #801 上提的那条 P1,我建这个套件时原样犯了一遍

原来只验 ^[0-9a-f]{40}$。任何 SHA 都能过,而报告里那个 SHA 可能根本不含镜像里被测的文件。

改法:构建时把 run.sh 在该 commit 下的 git blob 哈希作为 build-arg 传入,容器内就地重算并比对(blob 哈希 = sha1("blob <len>\0" + 内容),不需要容器里装 git)。

见证红两种,都真跑过:

传错的 blob            → FAIL: 镜像里的 run.sh 与 SOURCE_COMMIT 声称的不是同一份  (exit 1)
blob 对但文件被篡改    → 同上                                                    (exit 1)

第二种是关键:它模拟的正是「SHA 与被测字节脱钩」这个真实病症,而不只是参数写错。(篡改后已还原,git status 干净。)


这条 PR 到现在,四次里有两次是我犯了自己刚指出的那一类

同一条 PR 里我提出的两条判据,两条都在我下一件产出上失效了。说明"提出过一条判据"和"它对我自己生效"之间,还差一个机械步骤 —— 前者是我做的,后者目前靠审查补。

vansin pushed a commit that referenced this pull request Aug 13, 2026
审查(通信狗 FINAL MAJOR)指出 §1 自相矛盾:一边说报告作为 report-only
子提交落在 SRC 之上,一边自查却要求锚点「等于 git rev-parse HEAD」。
报告提交后 HEAD 合法地高于 SRC,stack 分支同理 —— 这条规则会把正确的
做法系统性判成假红。

这条尤其该修:我自己在 #835 上用的正是 report-only 子提交模式。
按第一版的规则,我的清单会否掉我自己的正确做法。

改成正确的不变量:
  锚点 == SRC(建镜像时传进去的 build-arg,即被跑的那份字节)
  且 git merge-base --is-ancestor "$SRC" HEAD

同时把最后一行判据的强度说清楚:git show SRC:<文件> | grep -c 只证明
「那一版的代码能产出这种输出」,是必要非充分 —— 不证明这一次的输出
就来自它。要证 provenance 得靠镜像与字节的绑定(例如把被测文件在该
commit 下的 blob 哈希传进容器里重算比对),而不是靠字符串出现过。

另修一处错链:§3「它自己通常不会检查这些要求(见第 8 条)」——
第 8 条讲的是扫描器校准,该指的是「失效四:门的结论建立在一个它
从不检查的前提上」。已改。
vansin pushed a commit that referenced this pull request Aug 13, 2026
⑥ SOURCE_COMMIT 只验格式不验字节
   原来只验 ^[0-9a-f]{40}$。任何 SHA 都能过,而审查指出提交进来的 report
   里那个 SHA 早于本套件自身 —— 那份证据无法从它自称的版本复现。
   改成与 test823 相同的做法:构建时把 run.sh 在该 commit 下的 git blob
   哈希作为 build-arg 传入,容器内就地重算比对(blob 哈希 =
   sha1("blob <len>\0"+内容),不需要容器里装 git)。
   已验脚本内算法与 git hash-object 结果一致;该机制的端到端红/绿在 #835
   上证过两次(传错 blob、blob 对但文件被篡改,都 exit 1)。

⑤ qa.yml 缺 test601 路径
   test798 的镜像 COPY 了 test601 的 race-worker.ts,而
   server/src/scheduled-tasks-http.test.ts 会执行它做「两个真 Hub 抢同一
   occurrence」。只改那个 worker 的 PR 不该跳过这道门。已在两处 paths 补上。
   (这 4 行原本只存在于 #798;若只合本 PR、把 #798 当冗余关掉,它们永远
   不会落地 —— 此前已在本 PR 记录过这个坑。)

② server 的 npm install 无 lockfile —— 我没有改,需要所有者决定
   实测:server/package.json 有 4 个依赖,4 个全用 caret 范围,且仓里没有
   任何 lockfile/shrinkwrap。所以同一个 commit 在不同时间构建确实会解析出
   不同依赖图,审查这条成立。
   但修法只有一条:提交一份 lockfile。那是仓库级的依赖钉死决策 —— 它影响
   每一次 server 构建,不只是这道门;而且生成出来的树我无法在这里验证是否
   仍然全绿。这不该由我单方面决定,如实留作待决,不假装已修。
本 PR 的 L0+L1 稳定红,失败行只有一句:

  FAIL: TEST823_SOURCE_COMMIT 必须是一个完整的小写 SHA(收到 '')

根因不在被测的门,在供给侧。qa.sh 里原本是一串逐套件的 elif:

  if   [[ "$t" == "test686-rest-shape-golden" ]]; then --build-arg TEST686_SOURCE_COMMIT=…
  elif [[ "$t" == "test765-batch-runtime-gate" ]]; then …
  elif [[ "$t" == "test766-bunx-preflight" ]];     then …
  elif [[ "$t" == "test746-setup-bun-pin" ]];      then …
  fi

本 PR 把 test823 加进了 L1_TESTS,但没人记得这里也要加一条 —— 于是
TEST823_SOURCE_COMMIT 是空串,门正确地 fail-closed。

只补一条 elif 能让它变绿,但下一个新套件还会踩同一个坑:
「注册了套件」和「在供给侧登记」是两处,分开就会漂。所以改成按名推导:

  testNNN-...  →  --build-arg TESTNNN_SOURCE_COMMIT=$(git rev-parse HEAD)
  qa-*-...     →  不传(与原行为一致,它们的门不要这个变量)

行为等价性验证(对当前 L1_TESTS 全部 18 个套件逐个模拟):

  test823-l1-concurrency-cap   → TEST823_SOURCE_COMMIT   (新增,本 PR 需要的)
  test686-rest-shape-golden    → TEST686_SOURCE_COMMIT   (与原 elif 一致)
  test765-batch-runtime-gate   → TEST765_SOURCE_COMMIT   (一致)
  test766-bunx-preflight       → TEST766_SOURCE_COMMIT   (一致)
  test746-setup-bun-pin        → TEST746_SOURCE_COMMIT   (一致)
  qa-cli-01 / qa-hub-05 / qa-node-03b / …  → 不传        (一致)

bash -n 退出码 0。

顺带记一条同类:#801 的红是同一个形状 —— run.sh 要求 TEST798_RUNSH_BLOB、
Dockerfile 接了线、workflow 的 docker build 从没传。都是「门要求 X,
供给侧不知道要给 X」。
vansin added 2 commits August 14, 2026 00:36
上一个提交(2bb734a)把逐套件 elif 改成按名推导 TESTNNN_SOURCE_COMMIT。
方向对,但**覆盖不全**:CI 照旧红在同一行

  FAIL: TEST823_SOURCE_COMMIT 必须是一个完整的小写 SHA(收到 '')

原因是仓里并存两套命名,而我只按其中一套推导:

  tests/test686-rest-shape-golden/Dockerfile   ARG TEST686_SOURCE_COMMIT
  tests/test765-batch-runtime-gate/Dockerfile  ARG TEST765_SOURCE_COMMIT
  tests/test766-bunx-preflight/Dockerfile      ARG TEST766_SOURCE_COMMIT
  tests/test746-setup-bun-pin/Dockerfile       ARG TEST746_SOURCE_COMMIT
  tests/test823-l1-concurrency-cap/Dockerfile  ARG SOURCE_COMMIT / ARG RUNSH_BLOB   ← 不一样

test823 的 Dockerfile 收的是 `SOURCE_COMMIT`,再由它自己组装
`ENV TEST823_SOURCE_COMMIT=${SOURCE_COMMIT}`。我传的是 TEST823_SOURCE_COMMIT,
名字对不上 → ARG 空 → ENV 空 → 门 fail-closed。它还要 RUNSH_BLOB(run.sh:28)。

这次两套都传。未被 Dockerfile 声明的 build-arg 只产生一条警告,不影响构建。

blob 等价性实测(本分支 head 上):

  git rev-parse HEAD:tests/test823-l1-concurrency-cap/run.sh
  { printf 'blob %d\0' "$(wc -c < run.sh)"; cat run.sh; } | sha1sum
  两者相同 —— 与 run.sh:31 的算法一致。

bash -n 退出码 0;对 L1_TESTS 里各形态逐个模拟,qa-* 仍不传。

🔴 记一条:上一版我验证了「四个旧套件行为逐条复现」,那个验证是对的,
但它只覆盖了我知道的那套约定 —— **我没有去核每个 Dockerfile 实际声明了什么 ARG**。
「与原行为一致」不等于「对所有套件都正确」。
第三次 CI 仍红,但**换了一种红法**,而且这次是我造成的。

前两次红的是 `TEST823_SOURCE_COMMIT 收到 ''`。那个已经修好了 ——
本次日志里 `source_commit=1f2ab57a…` 正常出现、blob 校验也过了。

这次红在:

  - cap=2      峰值=0 生效值=2 告警=0     FAIL cap2
  - 0(不限)   峰值=0 生效值=0 告警=0     FAIL zero_conc
  failures=2

**每个用例的峰值都是 0** —— 桩一次都没被调用。根因:

  scripts/qa.sh:17          set -euo pipefail
  test823 的 Dockerfile     只装 bash / ca-certificates / coreutils / procps —— **没有 git**
  test823 的 run.sh         桩了 docker 和 npm,**没有桩 git**

而我上一版把 `$(git rev-parse HEAD)` 从「4 个具名套件」扩到了「所有 testNNN 套件」。
于是在 test823 重放 qa.sh 的那个容器里:git 不存在 → 127 → set -e 当场中断 →
docker 桩一次没被调用 → 事件流为空 → 峰值恒 0 → 闸门自己的回归失败。

**这不是被测代码的问题,是我改出来的回归。**

修法:git 调用全部 `2>/dev/null || true`,取不到就不拼 build_args ——
无 git 环境下退回到「和我动手之前一样」的行为(不传 build-arg),
真 CI 里 git 在,照常传。

模拟验证(PATH 置空以制造无 git 环境,带 set -euo pipefail):
未中断,build_args 为空。bash -n 退出码 0。

🔴 教训:我改的是**一个会被别的门重放的脚本**。给它加依赖(git)时,
我只想着「CI runner 上当然有 git」,没想过它还会在一个刻意最小化的容器里被重放。
「这个环境肯定有 X」——当脚本本身是被测对象时,这句话要先证明。
vansin and others added 8 commits August 17, 2026 23:31
* fix(cli): stop `anet node start --accept-dev-channels` reporting dead nodes as started

Two independent false greens on this path, both measured while restoring 97
nodes after a power loss on 2026-08-17.

1. The success line was printed on the strength of `tmux new-session -d`
   returning. That call succeeds even when the inner `anet node start` refuses
   and exits 1 a moment later, so a refused node printed
   `✅ node "X" started detached (tmux session live; …)` and exited 0 — with
   `can't find pane: X` on the line directly above it. Byte-identical to a real
   success, so a batch restore counted 64/64 up when 6 had never started.

   Now: unstartable profiles are refused before anything is spawned (same
   resolveStartProfile check launchAgent runs, so the message is the real one),
   and success is claimed only after verifyNodeUp — the function `project up`
   already uses to decide whether a node came alive. The success line quotes
   its evidence (`pid N alive`) instead of asserting a session it never checked.

2. The 45 s auto-confirm window was spent on the wrong prompt. A workspace
   Claude Code has not seen before shows folder-trust FIRST; the watcher knew
   only the dev-channels markers, so it stared at a prompt it would not answer
   until the window closed, and the dev-channels prompt that appeared later was
   never confirmed. The node hung silently and the hub showed it offline
   (TM智空负责人 died exactly this way and needed two manual Enters).

   Now the watcher answers folder-trust too and restarts its clock, because the
   window is meant to bound the wait for ONE prompt, not for the whole
   trust-then-channels sequence.

A failed start deliberately does not kill the tmux session — a node stuck on a
prompt is one keypress from working — but the failure output names the session
and says `tmux has-session` will answer yes for it, since that is the criterion
batch callers use.

Verified against the two failure modes and the happy path in an isolated
workspace, with the inner agent stubbed:

  bogus runtime   before: ✅ exit 0   after: refusal on stderr, exit 1, no spawn
  dies on start   before: ✅ exit 0   after: ❌ exit 1 quoting the pane's reason
  trust sequence  before: ✅ exit 0, 46 s, node hung with no pid
                  after:  ✅ exit 0,  5 s, pid alive, both prompts confirmed

All 6 wiring assertions fail against the unmodified file; both pure-module
mutations turn their tests red. Package suite 454 pass, tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cli): apply the same verify-before-claiming rule to --tmux and codex co-presence

Auditing the other 54 `✅` claims in the CLI for the same class as the
--accept-dev-channels false green. Most are honest — `hub start`, the dashboard
launcher and the co-presence app-server all measure before they claim (a
/health fetch, a listener-pid scan, waiting for the `listening on:` line). Two
were not.

`anet node start <alias> --tmux` polls `tmux has-session` for 2 s and calls that
proof. It isn't: tmux registers the session before the inner command has
finished failing, so an unsupported runtime printed
`✅ tmux session "X" started detached` and exited 0 — the session was gone two
seconds later. Measured, then fixed with the same refuse-before-spawning check.
The narrower claim this path makes (the SESSION started, not the node) is left
as-is; it is true, and unlike --accept-dev-channels this path cannot promise a
working node because it never answers the prompts.

The codex co-presence launcher spawns three tmux sessions and then declares the
node 就绪. Only ① proved itself. Its OpenCode twin already checked its TUI
session before making the same claim, so the two sibling paths disagreed about
whether "ready" is measured; now they agree, and 就绪 requires all three
sessions to be alive at the moment it is printed.

Not verified end-to-end: the codex co-presence path needs a working codex, and
this account's quota is exhausted until 2026-08-20. The change mirrors the
OpenCode twin's shape exactly and only adds a failure path where a session is
genuinely absent.

The first version of the --tmux gate passed against the unfixed file — anchored
loosely enough that it found the OTHER branch's preflight. Rescoped to the
--tmux path itself. 3 of the 4 assertions now fail against both origin/main and
the previous commit; the fourth is the OpenCode twin, green on all three
because it is the reference, not a change. Suite 458 pass, tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cli): say which condition made a resolved agent-node unsafe, and name umask

Chasing why the 5 grok co-presence nodes were unstartable. The published anet
(2.3.0-preview.38) has no grok-build-cli in its runtime whitelist, so the
morning's conclusion was "rewrite their config to grok-build-acp". That was
wrong twice over: origin/main already whitelists grok-build-cli, and once past
that the real blocker turned out to have nothing to do with grok.

Measured chain on this machine:

  umask                                            0002
  npx -y @sleep2agi/agent-node@preview   dist/cli.js 0775, package.json 0664
  the check                              (mode & 0o022) !== 0  → refuse
  0o775 & 0o022                          = 0o020  (group-write alone)
  what the operator saw   [anet] Incompatible grok-build-cli runtime.
                          [anet] resolved agent-node package has unsafe ownership or mode

Owner was correct throughout (uid 1000, my own), so the sentence sent every
reader to look at ownership. `chmod g-w,o-w` on those two files made the same
command run all the way through to the agent-node process, failing only on the
fake hub the test config points at — which is what confirmed the diagnosis.

The check is right and stays: refusing to execute a payload the group can
rewrite is correct, and anet cannot know this box's group has one member. What
changes is that it now names the path, the octal mode, which of the four
conditions fired, and that the usual cause is a stock Debian/Ubuntu umask —
with both fixes spelled out. Ownership failures deliberately do NOT mention
umask, so that message stays about ownership.

Both call sites share the new pure module: the grok preview resolver in cli.ts
and the OpenCode pairing check, which enforces the identical rule and would
have produced the identical dead end. The existing assertion on the old wording
still matches — the sentence is kept as the headline and the diagnosis appended.

Suite 465 pass, tsc clean. The npx directory was left exactly as found
(775/664); the fix is the operator's to apply.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(doctor): warn about the umask that makes grok-build-cli and opencode-cli unstartable

A better error message only helps someone already stuck. `anet doctor` can see
this coming from local state alone.

Both runtimes refuse a resolved agent-node payload whose mode has a group- or
other-write bit. npm creates files as `0o666 & ~umask`, so a stock
Debian/Ubuntu umask of 0002 — every user gets a private group, so 0002 is the
distro default — guarantees 0775/0664 and guarantees the refusal, which reaches
the operator as "Incompatible grok-build-cli runtime" with no mention of umask.

doctor now reports two things, from the process umask and whatever is already
extracted under ~/.npm/_npx. It never fetches, so an empty payload scan means
"nothing extracted yet", not "safe" — the umask verdict is what speaks to the
next fetch. On this machine:

  ⚠  Package file modes: umask is 0002, so npm extracts packages
     group-writable. grok-build-cli and opencode-cli refuse to execute a
     payload in that state, and the refusal reads as an "Incompatible runtime"
     error. Start those runtimes under `umask 0022`, or run
     `chmod -R g-w,o-w` on the resolved package root.
  ⚠  Resolved agent-node payload: 2 already-extracted file(s) would be
     rejected right now, e.g. …/@sleep2agi/agent-node/dist/cli.js (mode 775).
     Fix: chmod -R g-w,o-w …/@sleep2agi/agent-node

A set umask bit means "withhold that permission", so the predicate reads
inverted from how the symptom presents; that inversion is why judgeUmask is a
tested function rather than an inline expression, and 0002/0022/0000/0077 are
each pinned. Reading the umask requires the POSIX set-and-return call — the
helper puts the old value straight back, verified equal on a second read.

Suite 474 pass, tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cli): address tmux sessions exactly — bare -t prefix-matches a sibling node

Every human-facing string in this CLI already spells the exact form
(`tmux attach -t '=<alias>'`, with a comment at the OpenCode co-presence
launcher explaining that a missing TUI would otherwise silently attach to the
bridge). Every tmux command the CLI actually ran passed the bare name.

Measured on this machine with only `zz-honest-probe-extra` alive:

  tmux has-session -t zz-honest-probe    → success  (it is not running)
  tmux has-session -t =zz-honest-probe   → failure  (correct)
  tmux kill-session -t zz-honest-probe   → killed zz-honest-probe-extra

The live fleet here has four colliding pairs — A站内容/A站内容牛,
A站数据/A站数据牛, P站测试/P站测试牛, P站运维/P站运维牛 — so each of the
three consequences is reachable today:

  * has-session false-positives → `node start --accept-dev-channels` prints
    `tmux session "X" already running — skipping spawn` for a node that is
    down, exits 0, and never starts it. Reproduced end-to-end: with only
    `anet-collide-兄弟` alive, origin/main skipped the spawn and left no pid;
    the fixed build started the node (pid alive) and left the sibling running.
  * kill-session reaps the sibling, and `node stop` reports success.
  * send-keys would deliver an Enter into the sibling's Claude UI — the worst
    of the three, since the prompt watcher fires it unattended.

All eight call sites now go through one helper: kill-session, has-session,
capture-pane ×4, send-keys ×2.

killTmuxSession additionally returns whether the session is actually gone. Its
`kill-session` failure is swallowed on purpose — a session that already exited
is the common case — so the only way to know is to look afterwards. `node stop`
now checks that and refuses to report a stop it did not achieve, instead of
deriving "killed" from the pre-kill has-session probe and notifying the hub
offline over a session that is still up.

Suite 478 pass, tsc clean. Fleet untouched at 89 sessions throughout; the
integration test uses its own `anet-exacttest*` names and cleans up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: t <t@t>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Follow-up to #895, one level up. That PR fixed the single-node start paths; this
is the same defect class in the batch entry point, and it is the one automation
actually calls.

`anet project up` already measures every node with verifySpawnedNodes and prints
each failure, so its OUTPUT was honest — unlike the single-node path, it never
claimed a dead node was started. What it did not do was set an exit code: both
projectUp and projectRestart returned normally, so a run that brought up 60 of
74 nodes exited 0.

That matters because this is the entry point scripts use. While reviewing a
boot-time sweep for this machine's ~74 agent nodes, the design leaned on a
post-flight tmux audit rather than on `$?` — and the reason turned out to be
load-bearing rather than stylistic: `grep -c process.exit` inside projectUp
returns 0. Any watchdog or CI step that trusted the exit code was being told the
fleet was fine.

`invalid` counts toward failure too. A node whose config cannot start was never
attempted, so exiting 0 hides it exactly as well as a crash does.

The gate runs after printProjectSummary so the operator still gets the full list
before the process dies, and a clean run returns early and stays at exit 0.

All 5 assertions fail against f565e9b and pass here. Suite 483 pass, tsc clean.

Co-authored-by: t <t@t>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Doc-only refresh from 通信狗 review (issue #639). No behavior changes,
no runtime/config edits — every touched file is markdown or a
package.json `description` field.

## Version facts sourced from npm at commit time

Verified via `npm view <pkg> dist-tags` on 2026-08-14:
  latest  : agent-network 2.2.21 / agent-node 2.4.13 / commhub-server 0.8.8
  preview : agent-network 2.3.0-preview.39 / agent-node 2.5.0-preview.31
            commhub-server 0.9.0-preview.29
  local anet: v2.3.0-preview.38 (matches preview channel, one behind head)

## Per-file changes

P0-1 CHANGELOG.md banner
  - Drop hardcoded "当前 stable 是 v0.10.11" (out of date).
  - Point readers at npm `latest` + docs-site/docs/changelog.md as the
    live source; keep the v0.10.15 archival anchor + v0.8.1 OSS-first
    note. File still an archive of pre-2026-04 v1.0.0-preview history.

P0-2 docs/getting-started.md
  - Runtime table now includes `grok-build-acp` (needs `grok login`).
  - Note that @Preview additionally ships `codex-app-server` and
    `opencode-cli`; the authoritative full runtime table is at
    anet.sh/guide/runtimes.
  - `anet ls` → `anet node ls` (matches current CLI).

P0-3 docs-site/docs/{,en/}guide/getting-started.md
  - Add a preview-channel warning next to the admin/anethub line:
    @Preview prints a one-time random password on first
    `anet hub start`, don't hard-code `anethub`. This aligns with the
    README + cli.md wording that already carries the note.

P0-4 AGENTS.md 项目结构
  - Drop "39 命令" / "4 runtime" hardcoded counts (both drift).
  - Point at docs-site/docs/guide/cli.md as canonical CLI list.
  - Split runtimes into stable (4: claude-code-cli / claude-agent-sdk /
    codex-sdk / grok-build-acp) + preview extra (2: codex-app-server /
    opencode-cli); mark `grok-build-cli` as unreleased in any channel.

P0-5 docs/version/README.md + docs/plans/release-plan.md
        + docs/version/0.11.0/README.md
  - Backfill preview matrix from `npm view <pkg>@preview` (was pinned
    at .34/.26/.20 — now .39/.31/.29). Added timestamp + reminder to
    re-check `npm view` before editing.
  - WAIC 7-月-下旬 anchor is out of the window; strike-through the
    completed date, keep the archival link to waic-release.md, and
    replace with "current promote status per release-plan".
  - release-plan.md defaults table gains a commhub-server column so
    readers see all three packages, not just two.

P1-6 docs-site/docs/guide/architecture.md
  - "14 张表" → "20+ 张表(含 sessions / tasks / nodes / users /
    networks / SkillHub / providers / vault 等,实数按 schema
    版本浮动)"; EN mirrors it. Fixes both the two mermaid diagrams
    and the paragraph.

P1-7 docs/architecture.md
  - Runtime paragraph now says "stable 4 + preview 2" and points at
    anet.sh/guide/runtimes as authoritative.
  - 14 cli.ts deep `#L<line>` anchors defanged (link stays, line
    number dropped — they rot every release; kept the function name
    in the link text so intent survives).
  - Directory tree gains an "已不完整,以仓库实际为准" note so
    readers don't treat it as canonical.

P1-8 server/package.json + server/README.md
  - package.json `description` now says "MCP tools (17 collaboration-
    core + node/provider ops tools; authoritative list at
    docs-site/docs/api/mcp-tools.md)" — was "and 17 MCP tools"
    (readers took it as the total).
  - server/README.md MCP section gains one line saying the 17 in the
    table are the collaboration-core subset; full list at
    docs-site/docs/api/mcp-tools.md.

P1-9 README.md + README.en.md
  - "能做什么" / "What it does" gain one bullet pointing at Codex TUI
    co-presence and OpenCode as preview-channel additions with a link
    to the Runtime page.

## Not touched (per review scope)

  - docs/v3-postgresql-design.md archive banner (do not edit)
  - upgrade-v2 archive banner (do not edit)
  - grok-copresence danger banner (do not edit)
  - runtimes 官方表 (canonical, do not edit)

## Verification

`grep -c` on the touched files confirms:
  - `14 张表` remaining in docs-site/docs/guide/architecture.md: 0
  - `39 命令` remaining in AGENTS.md: 0
  - stale `cli.ts#L<n>` deep anchors in docs/architecture.md: 0
  - `anet ls` (bare, without node prefix) in docs/getting-started.md: 0

Co-authored-by: t <t@t>
Four findings from the open-issue triage, each verified against origin/main
before touching anything. Two of the four numbers in those issues were wrong in
ways worth recording.

## qa.yml path filter missed tests CI actually runs (#860)

qa.yml fires on a path filter. Four directories CI executes were outside it, so
editing the test could not re-run its own gate — and the run looks identical to
a gate that passed on the new code:

  tests/test292-e2e-hard-gate        (referenced by a workflow path)
  tests/test686-rest-shape-golden    ┐
  tests/test765-batch-runtime-gate   ├ reached through scripts/qa.sh L1_TESTS
  tests/test766-bunx-preflight       ┘

#860 reported three; it missed test292-e2e-hard-gate. My own first scan
under-counted in the other direction — it matched `tests/testNNN` and so never
saw L1_TESTS, which names directories bare. The remaining ~160 directories under
tests/ are run by no workflow at all, and are deliberately left out: a filter
entry for an unrun test reads like coverage it does not have.

## …and a guard so it does not drift back

`.github/scripts/check-qa-trigger-coverage.py` asserts every CI-executed test
directory is in the filter. Three behaviours, each exercised:

  fixed repo            → exit 0, "all 7 CI-executed test dirs can re-trigger"
  f565e9b's qa.yml     → exit 1, names all four with the line to add
  L1_TESTS renamed away → exit 2, "no CI-executed test directories detected"

That last one matters most: if the parser stops matching, the honest answer is
"I can no longer see the denominator", not a clean run against nothing.

Its workflow intentionally carries NO `paths:` filter. It guards a path filter;
gating it on paths would let an edit to qa.yml's filter or to L1_TESTS slip past
the check that watches them — the same blind spot it exists to catch.

## public-script safety: TLS verification (#890)

check-public-script-safety.py flagged `rm -rf` outside our paths and unscoped
`pkill`, but nothing about `curl -k` / `--insecure` /
`wget --no-check-certificate` / `NODE_TLS_REJECT_UNAUTHORIZED=0`. These scripts
are fetched over https and piped into bash, so verification is the reader's only
defence against a tampered download; there is no legitimate reason for a script
published at a public https URL to skip verifying it. That meets the file's own
"only unambiguous rules" bar. Zero current hits — this one is preventive.

While adding it I hit a bug in the reporting: the hint was chosen by an
if/else whose `else` branch belonged to the kill rule, so every TLS finding
printed advice about `pkill -u`. Remediation text is now keyed by rule, and an
unknown rule exits 2 rather than borrowing another rule's advice — pointing the
reader at a problem they do not have is worse than printing nothing.

Verified with real exit codes (not through a pipe, where `$?` is the last
command's): known-bad fixture → exit 1 with the right hint on all three forms,
comment lines ignored; real repo → exit 0 across 6 scripts.

## dashboard min_uptime (#892)

deploy/dashboard/ecosystem.config.cjs had `min_uptime: 20_000` while
docs-site/docs/deploy/daemon.md documents 45000 and explains why: below the
time a failing process takes to exit, PM2 counts the start as successful,
backoff never engages, and a crash loop looks like normal restarts. A dashboard
rebuilt from this repo landed exactly in that gap. Aligned to 45000 with the
reasoning inline; `node -e require(...)` confirms it still parses.

Co-authored-by: t <t@t>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Four more from the open-issue triage. Each was verified against origin/main
first, and two of them turned out to be bigger than the issue said.

## docs/qa/weekly/2026-W19.md would not decode as UTF-8 (#887)

Three multi-byte characters were truncated mid-sequence, not one. The issue
reported the first; repairing it revealed the second, and that one the third.
The damage pattern is consistent — every case is `_italic text_` with the
character immediately before the closing `_` eaten — which points at a
truncating edit rather than a bad encoding.

The lost characters are NOT recoverable, so they are marked as damaged rather
than guessed. This is a QA weekly report; inventing a plausible character would
be worse than saying a character is missing.

## …and all 24 of its relative links were dead (#872)

Not "24 broken links" in the sense of a few typos: 0 of 24 resolved. The file
sits three levels deep and the links were written for two, so every `../../`
landed inside docs/ instead of at the repo root. Four more used a single `../`
for directories that live under tests/. All 24 now resolve — verified by
resolving each one against the filesystem, not by eyeballing the diff.

## docs/qa timings contradicted each other three ways (#871)

  docs/qa/README.md          ~16s warm
  docs/qa/strategy.md        ~16s warm
  docs/qa/v0-summary.md      ~93s local, ~40s CI
  v0-summary's own per-test table, summed:   156s

The issue framed this as "pick one and unify". None of the three can be picked,
because not one of them says what it measured — warm or cold, serial or
parallel, which machine. 156s serial against a 93s wall clock just means there
is parallelism nobody wrote down.

So the dead numbers are gone from README and strategy, replaced with
`time bash scripts/qa.sh` and the one fact that stays true: the per-test table
sums to 156s, anything lower implies parallelism, cold starts are worse.
v0-summary keeps its 93s but now says it is a 2026-05 measurement.

## scripts/verify-published-pins.sh and verify-release-tag.sh had zero callers (#862)

Both committed, both executable, both carrying the incident that motivated them
in their header — and `grep -rl` across .github/ and scripts/ found nothing that
invokes either. A guard nothing calls protects nothing, while its presence reads
as if the risk were covered.

Running verify-published-pins.sh by hand, for the first time, failed on its
first invocation:

    ❌ OPENCODE_AGENT_NODE_VERSION 期望 2.5.0-preview.31,
       产物里是: 2.5.0-preview.28
    1 个 pin 与已发布产物不一致 —— main 修了但用户装到的包没修

That is the exact distinction its own header says bit this repo three times in
one day, live in the published preview and undetected. (Independently confirmed
by hand earlier the same night: installing preview.39 demands agent-node
preview.28, while main's source constant reads preview.31.)

Now scheduled daily plus manual dispatch, with the exit codes mapped so that
"could not measure" does not become the same green as "measured and fine":
rc=2 (registry unreachable) fails with a notice saying the run verified nothing.
Per-PR would be wrong — it inspects the published artifact, which a PR does not
change.

## A guard for the first two

`.github/scripts/check-docs-integrity.py` checks UTF-8 validity across every
tracked .md and relative-link resolution under docs/qa/. Three behaviours, each
exercised: repaired tree → exit 0 (359 files, 80 links); f565e9b's W19 → exit 1
with 25 errors naming each; LINK_SCOPE pointed at a missing directory → exit 2,
"scope regression, refusing to pass".

It starts green, so it is not a backlog canary — a red here will always mean
something just broke. Link checking is scoped to docs/qa/ and says so: some
pages elsewhere link to generated paths, and a guard that cries wolf gets
disabled.

Co-authored-by: t <t@t>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…#901)

* fix(cli): revert `=name` on pane commands — it cannot resolve non-ASCII sessions

Regression I introduced in #895 and merged. #895 replaced eight bare `-t <name>`
tmux targets with `-t =<name>` to stop prefix matching. That is correct for
session-targeting commands and WRONG for pane-targeting ones.

Measured on tmux 3.4 with a session literally named `zz中文探针`:

    tmux has-session   -t 'zz中文探针'   rc=0     -t '=zz中文探针'   rc=0
    tmux kill-session  -t 'zz中文探针'   rc=0     -t '=zz中文探针'   rc=0
    tmux capture-pane  -t 'zz中文探针'   rc=0     -t '=zz中文探针'   rc=1  can't find pane
    tmux send-keys     -t 'zz中文探针'   rc=0     -t '=zz中文探针'   rc=1  can't find pane

This fleet's session names are nearly all Chinese, so #895 silently disabled the
dev-channels prompt watcher for essentially every node: capture-pane throws, the
watcher reads that as "session gone", returns false immediately, and the confirm
box is never answered. The node then sits on the prompt forever. That is worse
than the prefix ambiguity the `=` was added to fix, and it is the same failure
mode #895's second half existed to eliminate.

Caught on a live node. `SDK马` was sitting on the dev-channels box with a live
pid, and:

    capture-pane -t '=SDK马'    → rc!=0
    capture-pane -t 'SDK马:0.0' → rc=0, 16 lines, prompt visible

The exact-and-portable form for a pane is the coordinate
`<session>:<window>.<pane>`, resolved by listing panes and matching the session
name with string equality in our own code — which is both unambiguous and
encoding-agnostic, instead of asking tmux to disambiguate. `has-session` and
`kill-session` keep `=name`; they accept it for non-ASCII and still need the
prefix guard.

The watcher now re-resolves the coordinate on every poll rather than caching it:
a session may have no pane on the first iteration, and "no pane yet" must not be
mistaken for "prompt absent" — it keeps waiting and lets the deadline decide.

The wiring assertion fails against f565e9b..7752437 and passes here; the pure
parser is pinned for prefix siblings, missing sessions, non-zero pane indexes,
and malformed rows. There is also an integration test that creates a real
non-ASCII session and asserts the exact rc difference above, so this cannot
regress silently again. Suite 491 pass, tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cli): auto-confirm dev channels for every node that loads one, not only claude-code-cli

Second half of the same failure. #895's `=name` change stopped the watcher from
seeing the pane; this is why the watcher was never even asked to look at a whole
family of nodes.

`autoConfirmDevChannels` selected candidates with

    normalizeRuntime(n.profile) === "claude-code-cli" && channels has "server:"

but the runtime is not what causes the prompt — loading a `server:` channel is.
`claude-agent-sdk` nodes with `server:commhub` show the same confirm box, and
`claude-code` normalizes to `claude-agent-sdk`, so legacy-named nodes were
excluded too. Those nodes sat on the box forever during `project up` /
`node start --all` with no watcher assigned to them.

The correct predicate was already in this file. The #494 warning on the `--tmux`
path keys purely on `server:` channels with no runtime test:

    if ((resolved.profile.channels ?? []).some(ch => ch.startsWith("server:")))
      console.warn(`[anet] ⚠ this node loads dev channels (server:*): …`)

Two places answering the same question with different rules, and the narrow one
was the one doing the work.

Measured on this machine, all three with `channels: ['server:commhub']`:

    微信马       claude-code-cli    → was selected, came up (late, but up)
    评估m马      claude-agent-sdk   → not selected, sat on the confirm box
    I站工程马     claude-code        → not selected (normalizes to agent-sdk)

Widening is safe because dismissDevChannelPrompt is detection-gated: Enter is
sent only when the prompt's exact text is on screen, so a node that never shows
it times out without a keystroke.

Both assertions fail against main and pass here. One of them initially failed
against the FIXED code too — the new comment quotes the old predicate verbatim,
and a plain `toContain` matched the comment. The test now strips comment lines
before asserting absence, because the claim is about the code. Suite 493 pass,
tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: t <t@t>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* docs(refresh): 4 独立面 stale-snapshot caveats (task 27faa700)

Local-only branch. NOT pushed, NO PR opened — awaiting 通信龙 review after
PR #869 merges (per instruction 2026-08-14).

Doc-only, no behavior changes. Each edit adds a snapshot-date caveat and
points at the live source of truth (release-plan.md / `npm view`); no
existing evidence-anchored text was removed.

## 1. docs-site/docs/{,en/}preview/index.md:16 (章节标题死数)

- Old heading: "当前 preview = canonical(2.3.0-preview.34 / 2.5.0-preview.26,2026-07-16)"
- New heading: "当前 preview channel canonical build(snapshot 2026-08-14)"
- Added 1 paragraph with:
  - real 2026-08-14 npm-view numbers (agent-network preview.39 /
    agent-node preview.31 / commhub-server preview.29)
  - the main-source-vs-published-binary caveat (通信龙 Fact 1):
    preview.39 binary's embedded .d.ts pair still names
    agent-node@2.5.0-preview.28
  - install-via-@preview-tag reminder (already stated once above,
    reinforced with npm view <pkg> dist-tags recipe)
- English mirror updated with parallel wording.

## 2. docs/release/v2.3.0/plan.md:30 (GA-gate 6-week-old snapshot)

- Prepended one blockquote line marking the段 as a 2026-07-05 snapshot,
  pointing at release-plan.md as the live source, and citing the
  2026-08-14 real preview numbers.
- Kept the original "最后更新:2026-07-05 …" line intact for GA-gate
  milestone history.

## 3. docs/release/versioning-and-compatibility.md:37-43 (fleet snapshot table)

- Prepended one warning line: the first three rows are 2026-06
  preview-iteration snapshots; live numbers via release-plan.md +
  npm view.
- Renamed rows 39-41 to append "(2026-06 快照)" so readers cannot
  misread them as current.
- Added one new row "已发布 preview 头(snapshot 2026-08-14)" with
  real npm-view numbers + the .d.ts pair caveat (通信龙 Fact 1).
- Kept "v2.3.0 GA 目标" and "latest(稳定线)" rows unchanged (真值
  仍准).

## 4. docs/runbooks/feishu-channel-ops.md:11-18 (runbook 死数)

- Runbook section title now names as-of 2026-07-01 deployment
  snapshot + instructs to复核 real deployed version via
  `docker exec anet-feishu-local anet -v` (does not touch prod —
  runbook only tells the operator what to run).
- agent-network / agent-node rows retain the deployed values but
  add "当前 preview 头 2026-08-14 快照为 preview.39 / preview.31" +
  release-plan.md link for cross-check.

## Version facts used (npm view 2026-08-14)

| pkg | latest | preview |
|---|---|---|
| agent-network | 2.2.21 | 2.3.0-preview.39 |
| agent-node | 2.4.13 | 2.5.0-preview.31 |
| commhub-server | 0.8.8 | 0.9.0-preview.29 |

## Not landed here

- Q2 段全部 8+ 条 (行为句缺版本) — 需 git log 追溯精确 version anchor,
  单独任务处理 (通信龙 视 #869 merge 时机再拍)
- 通信龙 Fact 2 (anet node start ✅ vs tmux has-session) — origin/main
  未直接命中"看到 ✅ 就成功"教学句, README / clean-server.md / feature-
  audit.md 可通过通用页脚 note 批量处理, 单独任务处理
- docs/RELEASE-SOP.md:232/243 pairing caveat 详写 — 属 SOP 内部, 请
  RELEASE-SOP owner 决定文案
- docs/grok-build-runtime.md:111 (v0.10.11 anchor stale) — 属 Q2 段
  item 16, 单独 issue

* docs(refresh): bump snapshot date 2026-08-14 → 2026-08-17 (rebase day)

Per 通信龙 instruction on task 27faa700: snapshot dates should reflect
the rebase/push day, not the authoring day.

Re-verified `npm view <pkg> dist-tags` on 2026-08-17 — numbers unchanged
since 2026-08-14 authoring:

  latest  : agent-network 2.2.21 / agent-node 2.4.13 / commhub-server 0.8.8
  preview : agent-network 2.3.0-preview.39 / agent-node 2.5.0-preview.31
            commhub-server 0.9.0-preview.29

Fact-1 pairing caveat also still holds (通信龙 tested 2026-08-17 evening):
main-source constant `OPENCODE_AGENT_NODE_VERSION` = agent-node preview.31,
but the published preview.39 binary embeds a `.d.ts` pair naming
agent-node preview.28. They installed preview.28 to get opencode-指挥狗
to start.

---------

Co-authored-by: t <t@t>
…tes (#900)

Follow-up to PR #898 (task 27faa700). Doc-only; 5 files, +20 -7.

## Anchors nailed via git log -S / gh issue view (2026-08-18)

### 1) README.md:50 + README.en.md:50 — 一次性随机密码 anchor
Introducing commit: `3e4e190c` (PR #264 fixing #261 P0-2), merged
2026-06-28. First npm-published preview containing the behavior:
`@sleep2agi/agent-network@2.2.22-preview.4`. All subsequent
`2.3.0-preview.0..39` inherit.

Stable `@latest` at the time of writing is `2.2.21` — pre-dates the
fix. So @latest users still get the fixed default `admin` / `anethub`.
Older preview `≤ 2.2.22-preview.3` also pre-dates the fix.

Rewrote both README lines to state the anchor version, PR/issue links,
and the explicit "you are still on the fixed default if you're on
@latest 2.2.21 or preview ≤ 2.2.22-preview.3" caveat.

### 2) docs-site/docs/{,en/}guide/getting-started.md:97 — #450 anchor
Issue #450 is **OPEN** (verified `gh issue view 450`). Root fix landed
in PR #239 commit `1eff3a4d` on 2026-06-28. Vincent's 2026-08-09 audit
verified the fix in an isolated Docker probe on
`agent-network@2.3.0-preview.38` reaching SSE connected.

But #450 is not closed because four acceptance gates need to run
green before promoting to latest:
  1. promote a release containing 1eff3a4 to npm latest and repeat
     the cold-install journey against @latest
  2. exercise a >60s cold fetch scenario
  3. verify actionable DNS/registry/timeout/version failure
     classification
  4. run the credentialed idle → send_task → non-empty reply layer

So: current `@preview` (2.3.0-preview.39) has the fix; `@latest`
(2.2.21) still ships the bug. Rewrote the warning block on both zh
and en getting-started to name that split explicitly and provide the
workaround.

Method-note (for the audit trail): PR #239's title only names #237,
not #450 — so the standard `gh pr list --search "fixes:#450"` returns
nothing. The link is only recoverable via `git log -S` on the error
string. Worth remembering.

## Fact-2 notes (通信龙 D1-D3) — #895 / #896 in main NOT yet in npm

- PR #895 (`f565e9b8`) fixed `anet node start` false-`✅` /
  false-`started detached (tmux session live)` in detached scenarios.
  Merged to main.
- PR #896 (`40574a02`) fixed `anet project up / project restart`
  exit-code lie. Merged to main.

**Neither has been cut into an npm release yet** — so for anyone on
`@preview` (currently `2.3.0-preview.39`), the trap still exists. The
real check remains `tmux has-session -t "=<alias>"` — the `=` is
required (bare alias is a prefix match and can go green on the wrong
session).

Added this note in three places:

1. `README.md` + `README.en.md` quickstart — right after
   `curl /health` verify, before the "open localhost:3000" line, so
   the first-run reader sees the caveat while their brain is still
   on `anet node start`.
2. `docs-site/docs/deploy/clean-server.md` §7.1 — right below the
   `tmux new -s anet-<alias> + anet node start <alias>` recipe.
3. `docs-site/docs/deploy/clean-server.md` §故障排查表 — added a
   new row 5.5 (`✅ printed but tmux session not there`) with the
   diagnostic recipe.

Not touched (per current scope):
- `docs/version/0.11.0/feature-audit.md:33` (D4) — the audit already
  correctly reports the bug and its `✅` refers to `anet node create`
  (wizard), not `anet node start`; no misleading claim to correct.
- The 6-10 `anet node start` command demonstrations in
  `docs-site/docs/deploy/npm.md`, `.../concepts/networks.md`,
  `.../concepts/tokens.md` — those are pure command samples with no
  success-criteria text; the central note in clean-server.md is
  where they land.

## Not touched — Q2 leftovers (independent follow-ups)

Sixteen other Q2 lines identified in the exhaustive scan still carry
`TBD 追溯` anchors (multi-model.md, agent-node.md, batch.md,
dashboard.md, feishu.md, runtimes.md, upgrade.md, RELEASE-SOP.md,
etc.). Each needs a targeted `git log -S` pass. Scheduled for
subsequent follow-ups so this PR stays reviewable.

## Verification

```
$ git diff origin/main...HEAD --stat
 README.en.md                               | 8 ++++++--
 README.md                                  | 8 ++++++--
 docs-site/docs/deploy/clean-server.md      | 7 ++++++-
 docs-site/docs/en/guide/getting-started.md | 2 +-
 docs-site/docs/guide/getting-started.md    | 2 +-
 5 files changed, 20 insertions(+), 7 deletions(-)

$ git grep -c '2.2.22-preview.4' README.md README.en.md
README.en.md:1
README.md:1

$ git grep -c '1eff3a4d' docs-site/docs/{,en/}guide/getting-started.md
docs-site/docs/en/guide/getting-started.md:1
docs-site/docs/guide/getting-started.md:1

$ git grep -c '#895' README.md README.en.md docs-site/docs/deploy/clean-server.md
README.en.md:1
README.md:1
docs-site/docs/deploy/clean-server.md:2
```

## Version facts sourced from npm (2026-08-18)

Verified `npm view <pkg> dist-tags` on 2026-08-18 — unchanged since
2026-08-14 authoring pass:

  latest  : agent-network 2.2.21 / agent-node 2.4.13 / commhub-server 0.8.8
  preview : agent-network 2.3.0-preview.39 / agent-node 2.5.0-preview.31
            commhub-server 0.9.0-preview.29

These are snapshots, not promises. Both `@latest` and `@preview` tags
keep drifting; re-read via `npm view <pkg> dist-tags` before quoting
elsewhere.

Co-authored-by: t <t@t>
vansin and others added 19 commits August 18, 2026 04:21
run.sh 里那一行是:

    log "network: disabled by runner"

它挨着的每一条都真的在验:

    [ ! -e "$ROOT/.git" ]   || fail "image contains repository metadata"
    [ ! -e /root/.grok ]    || fail "image contains a Grok home"

**只有它在复述一个别人应该做过的事。**

而 [L2] 那一步的全部意义是「在没有网络的情况下构建候选包」。如果 runner 忘了
`--network none`,那一步照样绿——而它证明的东西并不成立。🔴 忘记加那个 flag 与
正确加了它,产出的证据逐字相同。

判据用直接观察:`--network none` 的容器里 `/sys/class/net` 只有 `lo`。

    net_ifaces=$(ls /sys/class/net | tr '\n' ' ' | sed 's/ *$//')
    [ -n "$net_ifaces" ] || fail "cannot read /sys/class/net — …refusing to claim it is"
    [ "$net_ifaces" = "lo" ] || fail "network is NOT disabled: …[$net_ifaces]…"
    pass "network is off (verified: /sys/class/net = [$net_ifaces])"

两个方向都收:读不到 `/sys/class/net` 时**拒绝声称网络是关的**(fail-closed,
而不是当成"看不见就是没有");看到第二个接口时点名它,并说明后面的绿因此不作数。

本机对照(有网的宿主):`/sys/class/net` = `docker0 eth0 lo` → 这条断言会红。

注:test224 目前不被任何 CI 引用(#861 统计的 180 个孤儿之一),PR #803 正在把它
注册进 qa.sh。本 commit 只修判据,不改注册状态——一道会被跑的假断言和一道不会被跑
的真断言,前者更危险,先修前者。

Co-authored-by: t <t@t>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…#819) (#925)

* test(server): 给 resolveRestWriteNetworkId 补测试——它是 network-scope.ts 里唯一没被任何测试点名的导出函数(#819)

同文件五个兄弟各有 1-2 个测试文件点名,只有它是 0:

    resolveRestNetworkScope 1   canRestWriteNetwork 1   singleNetworkId 2
    addNetworkScope 1           getUserNetworkIds 1
    resolveRestWriteNetworkId   🔴 0

而它决定的是**一次 REST 写入落到哪个网络**。它的 docstring 说明了一条不显然的
规则:管理员的**读**作用域按设计是全局的(networkIds=null),所以它本身表达不了
「这个管理员恰好只属于一个网络」;**写**不能继承这个歧义。

规则两半都钉了,因为它们坏掉的后果完全不同:
  - **放行那半**(admin + 恰好 1 个成员关系 → 用它)坏了 = 管理员写任何东西都要
    显式带 network_id。很吵,但安全,所以没人急着修。
  - **收紧那半**(admin + 0 或 ≥2 → null)坏了 = 一次写入**落到一个没被指定的
    网络里**,而调用方看到的是成功。

只写反向断言不够:一个「永远返回 null」的实现能通过所有收紧用例。所以每条收紧
断言都配了正控。

**变异验证(先红后绿)**

    未变异                                    10 pass / 0 fail
    A:函数体开头直接 return null             6 pass / 4 fail   ← 打掉「放行」那几条
    B:去掉 memberships.length === 1 判断      8 pass / 2 fail   ← 打掉「≥2→null」「0→null」
    还原                                      10 pass / 0 fail

两次变异都带 `assert 锚点命中 == 1`,确认不是 no-op。

🔴 **有一条断言我一开始写错了,改成记录留在文件里**:我以为「作用域为空数组 →
null」,实测是 NET_A。原因是 `[]` 过不了 singleNetworkId,于是落到 admin 的成员
关系回退分支。

追下去发现 `networkIds: []` 在 resolveRestNetworkScope 里有确切含义(`:46`):
**非 admin 请求了一个自己没有角色的网络**,带着 `denied: "access denied to
requested network"`。也就是说 `[]` 不是「没指定」,是「明确被拒」。

现在**没有**问题:那一行只对非 admin 产生(admin 在 `:42` 提前返回
networkIds:null),而非 admin 走到这里必然 null。但如果将来有任何路径让 admin
拿到 `networkIds: []`,这次写入会**忽略那条明确的拒绝**。所以两条都钉住了:当前
行为,以及那条让它安全的前提(并对前提本身也断了一次)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(ci): 把 #819 那条新测试挂进 L0——否则它是一条永远不会被跑的测试

🔴 自查发现的:`server/src` 下有 **71** 个 `*.test.ts`,而 CI 的 L0 只点名 **5** 个。
我在上一个 commit 里加的那条测试**不在这 5 个里**——也就是说,如果只加文件不改
qa.sh,我刚写的这条测试**在 CI 里一次都不会跑**。

这正是我这两天在别人代码里反复指出的形状(#861:204 个套件里 21 个被 CI 引用;
#817:门连分母都没有),而这一次是我自己差点交出去。

**够不够格进 L0,是量出来的不是假设的**:

    依赖链   network-scope → db → db-adapter(bun:sqlite) / auth(node:crypto)
             全部是 bun/node 内置,**不需要 bun install** —— 满足 L0 的零依赖预算
    耗时     538 ms
    对照     已在 L0 的 auth-tokens = 592 ms  ← 比它还快

(L0 的排除标准写在数组下方的注释里:observer-avatar-http 被排除是因为它启真
HTTP server、import 链需要 MCP SDK。这条测试不碰这两样。)

跑一次确认没破:

    ✓ L0 password-dict / auth-tokens / auth-validate / observer-push
    ✓ L0 avatar-validate / rest-write-scope
    ✓ ALL PASS in 2s

顺带留一个数字给 #798(它要给 server 补聚合单测门):**71 个里进 CI 的现在是 6 个**。
本 commit 只把新增的这条挂上,不动其余 65 个的归属——那是 #798 的范围。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: t <t@t>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
`waitForTmuxPaneText` 用的是 `capture-pane -p`——**不带 `-S` 时它只返回当前可见
区**。app-server 打完 `listening on: <ws>` 之后如果还有输出,那一行滚出屏幕,这个
轮询就再也看不到它,于是等满 25s 报「did not bind within 25s」——而服务早就绑上了。
#849 实测:**1.1s 绑上,25s 判失败**。

**本地复现(同一个 pane,先打 needle 再刷 200 行日志)**

    capture-pane -p            → includes(needle) = false
    capture-pane -p -S -500    → includes(needle) = true

判据是 `-S`,不是别的:这个函数找的是**曾经出现过一次**的那一行,不是「此刻屏幕上
有什么」。

🔴 **同一个 flag,四处调用里两处该加、一处不该、一处早就加了:**

    :249  waitForTmuxPaneText   找「曾出现过的就绪信号」   → 加 -S -200  ← 本条修的
    :818  bridge 尾部日志        找「曾出现过的上下文」     → 早就有 -S -80(正确写法一直在同一个文件里)
    :8016 capturePaneReason      找「曾出现过的失败原因」   → 加 -S -200
          一个已死 pane 的报错常被后续输出顶走;拿不到就回退成一句泛化文案,
          而真正的原因还在回滚里
    :7979 dev-channels 自动应答  判「此刻屏幕上有没有提示框」→ **故意不加**
          加上回滚,一个早被答掉、已滚走的提示框会被重新识别成待处理,
          于是往一个并没有显示它的会话里 send-keys

第四处旁边写了「故意不加」的理由,免得下一个人看到三处有一处没有就顺手补齐。

`bun build` 通过;`tmux-pane-prompt` + `tmux-exact-target` 单测 14/14。

Co-authored-by: t <t@t>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
`resolvePreviewAgentNodeEntrypoint` 原来是:

    } catch {
      throw new Error("could not install and resolve @sleep2agi/agent-node@preview");
    }

`execFileSync` 明明 `stdio: [..., "pipe", "pipe"]` 抓了 stderr,而 catch 把它整个
丢掉。

🔴 **这是全新安装第一次 `anet node start` 的必经之路**——agent-node 按设计由 npx
懒取(checkRuntimeDependency 里那句 `note: agent-node will be lazy-fetched via npx
on first start (this is normal)` 就是在说它)。所以它失败时,用户拿到的是一句**没有
原因**的话,而真正的原因就在被丢掉的 stderr 里:registry 不可达 / 权限 / 磁盘满 /
120s 超时——**每一种的下一步动作都不同**。

**stub 掉 npx(让它报 EACCES)实测对照:**

    修前:could not install and resolve @sleep2agi/agent-node@preview
    修后:could not install and resolve @sleep2agi/agent-node@preview
          --- npx said ---
          npm error code EACCES
          npm error syscall mkdir
          npm error path /usr/lib/node_modules/@sleep2agi

同一个形状在 docs-site/docs/public/install.sh 上修过一次(#908):那次是
`>/dev/null 2>&1` 吞掉首次尝试的 stderr、然后把每一种失败都叙述成「registry 失败」。
**这里更进一步——它连一个猜测都不给。**

细节:
- 超时单独点名(`npx exceeded the 120s budget`),因为 120s 超时和 npx 报错在原来
  那句话里完全一样;
- npx 一个字都没输出时明说 `(npx produced no output — check that \`npx\` itself works)`,
  而不是留一句空的原因;
- stderr 只取最后 8 行、截断到 1200 字符——够定位,不刷屏。

Co-authored-by: t <t@t>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
这个套件跑同一个测试文件三次(基线 / 变异后 / 还原后),三次都只看退出码。而退出码
分不出**「5 个测试全过」**和**「只注册到 1 个、它挂了」**。

🔴 2026-08-17 21:20 UTC 的 CI 上真的发生了后者:

    (fail) (unnamed) [5247.62ms]  ^ a beforeEach/afterEach hook timed out
     0 pass  1 fail
    Ran 1 test across 1 file. [5.47s]

同一个文件在正常环境是 `5 pass / 0 fail / Ran 5 tests / 620ms`。

摘要里那句 `0 pass 1 fail` 读起来像「跑了 1 个、挂了 1 个」——**没有任何一行说本该
跑 5 个**。另外 4 个既没跑,也没被提到。

改动:三个阶段各自 `tee` 到日志并断言 `Ran N tests` 中的 N ≥ GOLDEN_MIN_TESTS(5)。

用**下限**而不是等号:加测试是常态,加了不该让这道门红;**少跑了才是要抓的**。
下限旁边注了日期(截至 2026-08-18 实际为 5),免得它变成一个悄悄失去意义的常量。

🔴 **变异那一轮同样要断分母**——如果那一轮压根没跑起来,它也会「红」,而那是一个
**为了错误的理由变红**的 witnessed-red,证明不了变异真的被抓住。这是三处里最容易
被漏掉的一处:前后两次绿的断言很直觉,中间那次红的断言不直觉。

读不到 `Ran N tests` 时 fail-closed(「判不了跑了几个,拒绝通过」),而不是当成通过。

**离线验过判据本身**(三种输入喂给那个函数):

    正常输出(Ran 5 tests)            exit 0
    塌陷输出(Ran 1 test)             exit 1  "只注册到 1 个测试,下限是 5"
    垃圾输出(读不到 Ran N)           exit 1  "判不了跑了几个,拒绝通过"

`sh -n` 通过。

Co-authored-by: t <t@t>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
**一、5 条死链,指向的东西从来就不存在**

    report-test-v092-preview5.md   git log --all --diff-filter=A → 0 次新增
    report-test-v092-preview6.md   同上 0
    report-test-v092-preview7.md   同上 0
    v010-chain-test-baseline.md    同上 0
    ../../memory                   仓外目录

不是「文件被删了」,是**从未提交过**——这份已提交的文档引用的是只存在于某台机器上
的东西。(同一形状:docs/rfcs/RFC-022 链到 RFC-017,而 RFC-017 也不在 main 上。)

对照同一份文件里**能用的**那些引用:它们全部指向 GitHub issue。**这个文件自己的
主流写法是对的,坏掉的是少数几条指向本地文件的。**

改法:把链接去掉、保留归属,并说明那份报告未进仓。**每一条的内容本来就写在正文
里**(「bash backticks in echo strings → cmd substitution spawns interactive wizard
→ container hang. Use single quotes」),链接不提供任何额外可取回的东西。

**二、2 处内部 memory slug 出现在公开仓**

    :7    **Per [`feedback_docker_smoke_real_tty`]**
    :224  (per [[feedback_gate_evidence_must_be_runner_generated]])

第二处是双方括号形式,本该被 check-no-memory-slugs.py 抓到——但 `docs/tests/` 在
它的 ALLOWLIST_PATH_PREFIXES 里(那个豁免是有意的、写了理由的,见 #772)。
第一处是**单方括号**,任何一道现有的门都不匹配。

两处都换成**它们各自的理由本身**,而不是指向一个外人拿不到的 slug:

    :224  → 「证据必须由 runner 在被检对象之外产出,否则『被检的东西』和『检它的
             东西』来自同一次提交,红不了。」

🔴 **写下理由比写下 slug 名有用**:读者当场就能判断这条约束讲不讲得通,而 slug 名
只告诉他「有个地方记着这件事,而你进不去」。

验证:本文件剩余死链 **0**、剩余 slug 引用 **0**;`check-no-memory-slugs.py` exit 0。

Co-authored-by: t <t@t>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
这四个套件从 2026-07-02 起就是红的,而**没有任何东西会跑它们**,所以没人知道。
#861 里实测过:4/4 退出码 1,全部死在第 2 步,错误完全相同。

## 三处独立的漂移,逐个修

**① report_status 的身份绑定(#203 / #376,2026-07-02)**

`server/src/tools.ts:584` 之后,用 network token 上报时 args 里的 alias 必须等于
该 token 绑定的 alias。而 `callerAlias` 的推导(`server/src/server.ts:733-735`)是:

    tokenName 以 "node:" 开头 → 取其后缀;否则**回落到用户名**

四个套件都拿 `register_user` 返回的 `network_token`(名字不是 `node:…`)去上报
任意 alias,于是一律 `alias_identity_mismatch`。

修法:加 `node_token()` helper,为每个要上报的 alias 铸它自己的 node token
(`POST /api/auth/node-token`)。取法与**已注册且长期绿**的 `qa-hub-05-roundtrip`
完全一致 —— 不是我发明的写法。
qa-hub-12/13 上报多个不同 alias,所以再加一层 `report_as()`:直接从 args 里取
alias 再铸 token,循环调用点不必逐个改、也不会漏。

**② send 侧的对称检查(fromIdentityMismatchReply)**

用 network token 发送时 `from_session` 也必须等于 token 绑定的 alias。
qa-hub-10 / qa-hub-13 的发送方同样改成持有自己 node token。
两处的断言(`"from":"alpha-sender"` 等)**原样保留**。

**③ qa-hub-10 第 3 步断言的是一个已被有意修掉的 bug(#517)**

原断言:utok 不带 network_id 发送 → `permission_denied: network_id required`。
`#517` 的标题就是「节点发消息报 permission_denied: network_id required,而工具 schema
没有这个入参(**一晚三个节点抄送全部静默失败**)」—— 那个报错本身是 bug,
修法是单网络 utok 自动解析。

所以这一条**改断言**(参照 #804 / test682:产品有意改掉的东西,该改断言而不是把行为改回去),
改成断言新的正确行为:自动解析并投递成功。这是四个套件里**唯一**被改掉的断言。

## 🔴 一个我猜错、被实跑纠正的假设

我原本怀疑 qa-hub-13 测的端点被改名了(`/api/server/:host/health` vs 产品里的
`/api/server-health/:host`),依据是 `git grep "/api/server/"` 在 `server.ts` 里 0 命中。
**证据看起来很硬,但结论是错的** —— 修完之后:

    [4] /api/server/:host/health exposes latest alert + history for network A only   ✓
    [5] /api/server/:host/agents exposes per-agent details and process telemetry     ✓

两个端点都在,都正常。静态比对给出的是「哪里可疑」,不是「实际会怎样」。

## 验证:四个都实跑到绿

    qa-hub-10  rc=0  PASS network scope regressions (#67 message ✓ / #54 SSE isolation ✓)   7 步
    qa-hub-11  rc=0  PASS node-delete-sse (#74 node_deleted push ✓ / network isolation ✓)   5 步
    qa-hub-12  rc=0  PASS servers endpoint (#119 host telemetry aggregation ✓)              5 步
    qa-hub-13  rc=0  PASS server health/agents endpoints (#140 Hero 1+2 ✓)                  9 步

exact `origin/main` 上构建运行,跑完逐个删镜像。
**这些断言此前一次都没被执行过** —— 它们全部倒在第 2 步。

## 本 PR 不做注册

按 #861 里定的次序:先修好、确认能绿,**再**谈要不要进 `L1_TESTS`。
注册是另一次改动(且会影响 CI 时长),应当单独决定。
若决定注册,`tests/qa-*/**` 已在 `qa.yml` 的 paths 里,不用改 paths(见 #860)。

Co-authored-by: vansin <t@t>
* test(ci): 给 server 补上聚合单测门(69 个单测此前 CI 只跑 6 个)

server/src 下 69 个 *.test.ts,CI 可达的只有 6 个(scripts/qa.sh 的 L0_TESTS
点名 5 个 + test686 引用 1 个),另外 63 个没有任何 job 会碰。server 是 hub 本体
—— 认证、token、网络隔离都在这里,盲区比 agent-network 那 46 个严重。

形状抄 test745/test725,但按 server 自己的契约做了两处改动:

1) 逐文件跑,每个文件一个独立 DB。scripts/qa.sh 的 L0 本来就是
   `COMMHUB_DB=/tmp/qa-l0-$name.db bun test <one-file>` —— 这是既有契约。
   用一个共享 DB 聚合跑会红 4 条(admin-networks 的 global-admin 可见性、
   scheduled-tasks 三条),而这 4 条单跑全绿,是跨文件状态污染。
   把"聚合能不能跑"当门等于给它加了一条它从没承诺过的性质。

2) cwd 必须是仓根。task-lifecycle-watcher 用 process.cwd() 拼
   ./server/src/db.js,scheduled-tasks-http 按仓根相对路径 import
   tests/test601-.../race-worker.ts。从 server/ 目录跑会让这两个红在路径上,
   看起来像产品坏了。

红线:COMMHUB_DB 不设默认指向生产库。容器里够不到宿主的库,但不靠"够不到"
保证 —— run.sh 显式钉到 /tmp 并断言钉住了。31/69 个测试引用 sqlite/COMMHUB_DB。

分母承重:executed_files 必须等于 find 出来的 test_files,少一个就红。

witnessed-red:把 auth.ts 注册密码下限 `< 8` 改成 `< 1`(7 位密码会被接受,
一条真的安全回退),先校验字节非 no-op,再要求红落在指名的
"rejects 7-char password" 上。

实测:test_files=69 executed_files=69 failed_files=0,
MUTATION_RED registration-password-floor-weakened rc=1,RESULT: PASS,耗时 51s。
完整输出见 docs/tests/report-test798-server-unit-ci.txt。

* ci: server 单测门抽成独立 job,别挂在 agent-network 名下

上一版把 build/run 两步插进了 agent-network-unit job 里,所以它确实跑了
(CI 日志实测 test_files=69 executed_files=69 failed_files=0
MUTATION_RED registration-password-floor-weakened rc=1 RESULT: PASS),
但会以 "agent-network unit (Docker, non-root)" 的名义显示 ——
server 挂了会归错帐,而且两个重 Docker build 串在一个 job 里。

抽成 server-unit job,显示名 "server unit (Docker, non-root)"。

* ci: test601 的 race-worker 也要能触发 server 单测门

自查清单第 4 条(判据范围要与被判对象一致)在自己 PR 上的第一次应用:
把 test798 镜像 COPY 的每一项,回去核 qa.yml 的触发路径有没有覆盖。

  COPY server ./server                      → 'server/**'        ✅
  COPY agent-node/src ./agent-node/src      → 'agent-node/**'    ✅
  COPY tests/test601-hub-scheduled-tasks    → 无                 ❌

server/src/scheduled-tasks-http.test.ts 会执行那个 race-worker 做
「两个真 Hub 进程抢同一个 occurrence 恰好一次」的用例 —— 只改 worker 的 PR
不该跳过这道门。两处 path 过滤都补上。

这条是 codex 在 #798 上提的 P2,当时我认了但没修;现在按清单扫一遍就扫到了。

* test(ci): mutation 的命名断言要锚在 (fail) 行,否则通过时也会命中

自查清单(#815)第 ⑤ 条「断言要精确到不合规会被拒绝」在自己门上的应用。

原来写的是 `grep -Fq 'rejects 7-char password'`。bun test 对每个用例都打
`(pass) <名字>` 或 `(fail) <名字>` —— 只 grep 名字的话,那条用例**通过**时
也会命中。于是这条断言只证明了「这条用例存在」,而不是「红落在它身上」。

A/B(把断言指向一条在该 mutation 下**不会红**的用例
`accepts 8-char strong password`,其余完全不动):

  松版 grep -Fq '<名字>'                → rc=0  RESULT: PASS   ← 收下了不合规
  严版 grep -Eq '^\(fail\).*<名字>'      → rc=1  FAIL: mutation red did not reach the named…

改成锚定形式后正常绿:MUTATION_RED registration-password-floor-weakened rc=1,RESULT: PASS。

同类问题在 tests/test725-agent-node-unit-ci/run.sh 也有(它 grep 的
'the inbox choke point feeds the augmented text into processTask' 同样是测试名);
在 #800 里一并收紧,那边有单独说明。
tests/test745 那条不受影响 —— 它 grep 的是断言失败信息
`Expected to contain: "anet config [path|json]"`,只在失败时出现。

* test(ci): 分母要有绝对下限 —— 删掉 85% 的测试,这道门原来照样绿

自查清单(#815)第 ⑥ 条「mutation 要跑到曾经活下来为止」的直接产物。
这道门原来只有「削弱被测代码」一个 mutation 维度。换一个维度试:删测试文件。

第一次删 60/69 时门红了 —— 但那是**碰巧**:mutation 靶点所在的
auth-validate.test.ts 恰好在被删之列。做决定性验证,删 59 个但保留它:

  test_files=10  executed_files=10  discovered_files=10  failed_files=0
  MUTATION_RED registration-password-floor-weakened rc=1
  RESULT: PASS        rc=0

**门放行了一个删掉 85% server 单测的改动。**

根因:`executed >= discovered` 只能抓「runner 跳过了文件」,抓不到
「文件消失了」—— 分母跟着现实自动缩水,原来的 `-gt 0` 形同虚设。

加 SERVER_TEST_FLOOR=60,并写明「真删了测试就故意改它,并在 PR 里说明」。
双向验过:69 个 → RESULT: PASS;同一删除 mutation → rc=1
`FAIL: only 10 server test file(s) under src/, floor is 60`。

* docs(tests): report-only —— 修掉报告内部两个不一致的锚点

自查发现本报告里有两个不同的 source_commit:抬头是 2617987(正确,==源码提交),
但嵌入的运行输出里是 187a6ff。

根因是我上一轮的操作顺序错了:**先 `git rev-parse HEAD` 打戳、后提交下限改动**。
于是镜像里跑的是含下限的代码,戳进日志的却是提交前的 SHA ——
证据本身有效,但它自称的锚点指向一个不含该改动的提交。

这与 #798/#800/#803 早先被独审抓到的假锚点是**同一个根因的第二次发作**
(那次是把 --build-arg 传成了 origin/main,这次是传成了未提交前的 HEAD)。

已在真源码提交 2617987 上重跑并重出报告,全文 source_commit 只指向一个值:
  test_files=69 executed_files=69 failed_files=0
  MUTATION_RED registration-password-floor-weakened rc=1
  RESULT: PASS

---------

Co-authored-by: vansin <smartflowaiteam@gmail.com>
Co-authored-by: t <t@x>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* ci: 注册三个从没进 CI 的 Docker 门,并把 build-arg 从硬编码链改成推导

tests/ 下有四个形状完整的 Docker 门(Dockerfile + run.sh + 自己的 mutation)
从没被注册进 L1_TESTS,所以一直没人跑。逐个跑过之后:

  test224-grok-preview-security      PASS  39s
  test597-dashboard-slash-namespace  PASS  15s
  test679-task-trace                 PASS  36s
  test682-uncovered-task-trace       FAIL  ← 不注册,另开 issue,见下

三个通过的注册进 L1_TESTS(L1 并行跑,最差加 ~39s 墙钟)。

顺带把 build_args 从硬编码 if/elif 链改成从套件自己的 Dockerfile 推导。
那条链的失效方式是静默的:把套件加进 L1_TESTS 却忘了加分支,它会在没有
SHA 绑定的情况下跑,输出看起来一切正常。而新加的 test224/test597 用的正是
不带前缀的 `ARG SOURCE_COMMIT`,是原链无法表达、只能再加分支的形状。

替换前核过等价性:对原链覆盖的 test686/765/766/746 四个套件,推导结果与
硬编码逐字相同。

推导是否承重,分三种(不传 build-arg 时):
  test224 → rc=1 FAIL: SOURCE_COMMIT must bind…   fail-closed,推导承重
  test597 → rc=0 PASS                             声明了却不强制
  test679 → rc=0 PASS                             声明了却不强制
后两个是那两道门自己的弱点,本 PR 不修,写进 NOT COVERED。

test682-uncovered-task-trace 不注册:它断言 cli.ts 里 sendPeerReplyTaskWithTrace(
恰好出现 1 次,实际 0 次。查下来不是烂了,是**过时了** —— #698 有意把 peer reply
改成协商 send_peer_reply 原子工具,那条 send_task 老路被删掉,并由
agent-node/src/reply-routing-source.test.ts 断言它**不得出现**
(expect(source).not.toContain("sendPeerReplyTaskWithTrace({"))。
两道门方向相反,而后者在 CI 里跑着且是绿的。另外
agent-node/src/peer-reply-task-trace.ts 现在零生产调用方,只被 test682 自己引用。
单独开 issue,不在本 PR 里删任何东西。

* fix(ci): build-arg 推导要 || true —— pipefail 让它打死了整个 L1 runner

第一版在 CI 上挂了,而且挂得很有欺骗性:失败停在
`· build qa-cli-01-hub-start`,一个套件都没跑成,看起来像「L1 挂了」,
实际是参数推导那一行把 runner 打死了。

根因:scripts/qa.sh 是 set -euo pipefail,而多数套件的 Dockerfile 根本没有
ARG SOURCE_COMMIT —— grep 无命中退 1,pipefail 把 1 传给整个命令替换,
set -e 于是在第一个这样的套件上退出。

我上一版只验了「推导算出来的参数名对不对」(对 7 个套件逐个核过),
没验它在 qa.sh 里跑不跑得通 —— 验了零件没验装配。

修法:命令替换末尾加 || true,并把原因写进注释。

witnessed-red(在真脚本上,不是最小复现):
  去掉 || true → rc=1,日志停在 `· build qa-cli-01-hub-start`,与 CI 症状逐字一致
  加回 || true → 三种 Dockerfile 形状各取一个跑真 qa.sh --l1:
    qa-cli-01-hub-start          无 ARG            ✓ PASS
    test765-batch-runtime-gate   TEST765_ 前缀 ARG  ✓ PASS
    test597-dashboard-slash-namespace  裸 ARG       ✓ PASS
    ✓ ALL PASS in 84s

* ci: 三个孤儿门改放独立 job,不塞进 L1

上一版把 test224/test597/test679 加进了 L1_TESTS。选错家了。

CI 上 L0+L1 job 的真实耗时(main 近四次):141s / 135s / 148s,预算 300s,
余量约 150s。而 qa.sh 的 build 是**串行**的(只有 docker run 并行),这三个
套件要各加一次 build,其中 test679 带 javascript-obfuscator;单跑 run 已是
39s / 15s / 36s。L1 自称「~16s parallel」,是快层 —— 塞进去是拿余量赌。

改成 qa.yml 里的独立 job `recovered-suites`,预算 12 分钟,形状同单测门。
撤出 L1_TESTS 的原因写进了那里的注释,免得有人再塞一次。

build_args 推导保留在 qa.sh —— 它独立成立:原硬编码 if/elif 链的失效方式是
静默的(套件加进 L1_TESTS 却忘了加分支,会在没有 SHA 绑定的情况下跑)。
等价性核过:对 test686/765/766/746 四个套件,推导与硬编码逐字相同。

三个套件按 job 里逐字相同的命令验证(只传 --build-arg,run 不带 -e):
  test224  SOURCE_COMMIT          rc=0  Summary: PASS
  test597  SOURCE_COMMIT          rc=0  RESULT: PASS
  test679  TEST679_SOURCE_COMMIT  rc=0  RESULT: PASS

NOT COVERED:不传 build-arg 时只有 test224 是 fail-closed(rc=1),
test597/test679 照样 PASS —— 它们声明了 SOURCE_COMMIT 却不强制。
那是那两道门自己的弱点,本 PR 不修。

* ci: test224 必须带 --network none;tests/lib/** 补进触发路径

两条都是独立审(codex)在本 PR 上提的 P1,核过属实。

1) test224 是安全套件。它的 Dockerfile 第 13 行明写
   「the actual gate is run with --network none」,run.sh 第 160 行会打印
   「runtime executed with network disabled」。而我的 job 是裸 docker run --rm ——
   **那句话在网络实际可用时照样打印**。

   实测对照:带与不带 --network none,两次都 rc=0、都打印同一句 Summary,
   差异只有时间戳和 tarball sha256。也就是说**套件自己不会拦住这个错误**,
   只能由调用方保证。这是我引入的缺陷:把一道安全门接进 CI 时没照它自己的契约调用。

2) test224 的镜像 COPY 了 tests/lib/safe-rm.sh 并 source 它,但 qa.yml 的两处
   path 过滤都没有 tests/lib/** —— 只改那个 helper 的 PR 不会触发这道门。

有一条我**不在本 PR 里改**:套件用一行硬编码 log "network: disabled by runner"
**声明**前提,而不是探测它。要让它自己红,得加 fail-closed 探测(比如真去
resolve/connect 一次,通了就 fail)。那是改别人的门、会影响所有调用方,
交给 owner 决定,我只报不动。

codex 另外三条我的处置:
- 「pin oven/bun digest」:成立,但属于 test224/test597 自身的 Dockerfile,
  与 #799/#802 的 pin 工作同族,不夹进本 PR;
- 「report 写在容器里被 --rm 丢掉」:成立,是观测缺口,同样属套件自身;
- 「把安全套件排在低层套件之后」:是取舍不是缺陷,独立 job 里三个都会跑完,
  排序不影响是否产出证据。

* docs(tests): report-only —— 锚点 aeec4b9,含 --network none 的对照与四条 NOT COVERED

* ci(qa): 落实三条已接受未实施的意见 —— 顺序、产物、Bun 输入

这三条我在窄审后都写过"成立",然后挂在"待收口后落"。收口从没到来,
而这条 PR 的意见已经躺了一天。不再等。

(e) 安全套件排在最前,与 CLAUDE.md 的分层规则相反
    「分层测试:环境→认证→单点通信→完整流程→多用户→安全」
    「前一层不过就不跑后面的」
    改成 test597 → test679 → test224(安全最后)。后果不是"跑了会错",
    而是底层套件红时安全证据已经先产出 —— 而那份证据的前提没成立。

(f) --rm 把套件报告删掉。test224 把 report-test224.txt 写在容器内
    /artifacts 下,--rm 随即删掉那个文件系统;test597/test679 只有 stdout。
    结果是三个套件每次 CI 都真跑,跑完什么都不留。
    改法:test224 挂出 /artifacts;三个都 tee 到 $RUNNER_TEMP/suite-artifacts;
    加 upload-artifact 且 if: always()(红了才最需要看输出)。
    🔴 三处都显式 set -o pipefail —— GitHub Actions 默认 shell 是 bash -e,
    不带 pipefail,不加这句 tee 的 0 会盖掉套件的非零退出。这正是本仓
    #805 上被判 MAJOR 的同一个形态,不能在修别的问题时又引进来。

(d) Bun 输入未钉死。test224 的 oven/bun:1.3.1 与 test597 的 oven/bun:1.3.14
    都是可变 tag —— 同一个 commit 在不同时间构建会跑在不同字节上。
    已钉成 digest(manifest inspect 取得)。

    ⚠️ test679 仍未钉:它是 node:22-bookworm-slim + curl bun.sh/install | bash,
    构建时装到什么算什么。改成仓里既有的"下载指定版本 zip + 校验 SHA256"
    (test745 的做法)属于改动该套件的构建方式,我没有实跑过它,不敢
    盲改。这一条如实留作 NOT COVERED,不假装已修。

* ci(test679): 钉死 Bun 输入 —— 上一版我标了"需先实跑"就留着没做

上一版我把这条列为 NOT COVERED,理由是"改构建方式需先实跑,盲改可能让
本来能跑的套件跑不起来"。那个理由成立,但消除它的办法就是先跑一次 ——
而我没跑。

这次跑了,而且不必重建整套:风险只在装 bun 那一层,所以隔离验证那一层。
两个最小镜像(原样 curl|bash vs 钉死下载+校验和)都构建成功,结果完全相同:
  bun 版本  1.3.14  ==  1.3.14
  路径      /root/.bun/bin/bun  ==  /root/.bun/bin/bun
所以转换今天是等价的,而且从此确定。

顺带这也证明了风险是真的:不钉版本时"今天恰好是 1.3.14",bun 一发 1.3.15,
同一个 commit 就会构建在不同字节上 —— 而套件本身不会察觉。

验证边界:我验的是 bun 那一层,不是整套 test679 通过。后面三个 bun install
与 run.sh 未动,但它们没有被重跑过 —— 首次 CI 运行才是完整证据。

* ci(recovered-suites): 上传前修正产物属主 —— 门全绿却因 EACCES 判红

exact-head CI 上 `recovered suites (Docker)` 稳定红,但红的**不是任何一道门**:

  RESULT: PASS                                        (×2)
  PASS: targeted Docker context contains no host auth/config state
  PASS: real child env equals the reviewed set; …
  PASS: candidate tarballs contain runnable entrypoints …
  Summary: PASS (Docker-only; runtime executed with network disabled; …)

红在最后一步 `Upload recovered-suite artifacts`:

  With the provided path, there will be 4 files uploaded
  ##[error]An error has occurred while creating the zip file for upload
  Error: EACCES: permission denied, open '.../suite-artifacts/report-test224.txt'

三个 suite 都是 root 容器写进 bind mount(`-v "$RUNNER_TEMP/suite-artifacts:/artifacts"`),
产物属主 root、mode 0600;upload-artifact 以 runner 用户打包,打开即 EACCES。
注意 `if-no-files-found: warn` 且日志明说「4 files uploaded」——
不是「没找到文件」,是找到了读不了。

后果不是 cosmetic:这个 PR 的目的正是把三道长期失联的信号恢复成 CI 里的常驻门,
而现在 job 必红、证据也归档不了,等于恢复了个红灯。

修法:上传前把产物目录的属主/权限归一化。用 `if: always()`,因为前面步骤红时
更需要把证据传出来。

---------

Co-authored-by: vansin <smartflowaiteam@gmail.com>
Co-authored-by: vansin <t@t>
Co-authored-by: t <t@x>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
`startHub owns a live watcher timer` 这一条在 CI 上红了一次:

    (fail) startHub owns a live watcher timer ... [5000.57ms]
      ^ this test timed out after 5000ms.
     4 pass  1 fail   Ran 5 tests across 1 file.

它不是偶发慢,是**结构上就没有余量**:两处定长 `Bun.sleep(800)` + `Bun.sleep(3_200)`
合计 4.0s,而 bun 每条测试默认预算 5.0s —— 剩 1.0s 要装下两次 bun 进程启动
(`bun -e import db.js` 初始化 + `bun run server/src/index.ts` 起一个真 hub)。
本机够,CI 里(冷 bun、Docker、72 个文件排队)不够。

改动:

1. **等事件那处改成轮询**。巡检周期是 `COMMHUB_DELIVERED_STALE_PATROL_MS=25`,
   事件在插入后几十毫秒就该出现,3_200ms 纯粹是余量。轮询后常态快 ~60 倍,
   慢的时候等得起(上限 20s)。

2. **等进程那处不假装在等就绪**。
   🔴 我第一版写的是「等到 db 文件存在」—— 而那个文件在上一步 init 里就已经
   建好了,条件恒真,等于没等。**一个不是目标状态独有的等待条件,和没有等待
   是一回事,但读起来像有。** 现在这里只保留原断言的原意(子进程没有立刻崩):
   在 800ms 窗口内轮询「它是否退出了」,一退出就立刻停下,不睡满。

3. **给这条加显式 30s 超时**。它要起两个真进程,默认 5s 对它本来就不成立。
   前两处改完常态用不到这个上限;它只保证「慢」不会被报成「坏」。

`waitUntil` 到期时把**在等什么**写进异常消息 —— 定长 sleep 超时最坏的地方不是慢,
是红落在「事件没写」这条断言上,读的人会去查 watcher,而真实原因可能是 hub 还没起来。

为什么现在才暴露:这个文件**从来没进过 CI**,直到 #798 把 server/src 下 72 个
测试全接进去。

⚠️ 本地没跑:这条会 `bun run server/src/index.ts` 起一个真 hub,不在宿主机上跑。
仅做了转译检查(`bun build --external '*'` → Bundled 1 module,rc=0)。
**判据是 CI 里的 `server unit (Docker, non-root)`。**

Co-authored-by: t <t@x>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
test682 断言 cli.ts 里 sendPeerReplyTaskWithTrace( 恰好出现 1 次。#698 有意把
peer reply 改成协商 send_peer_reply 原子工具,那条老路被删,所以 main 上它出现 0 次
—— 这道门跑起来是红的。它从没注册进 L1_TESTS,所以一直没人发现。

而且方向和现役的门相反:agent-node/src/reply-routing-source.test.ts 断言

  expect(source).not.toContain("sendPeerReplyTaskWithTrace({");

一次都不许有。那道门在 test725 覆盖下、在 CI 里跑着、是绿的。两道门不可能同时满足。

顺带删掉同源的两个死模块(都只被测试引用,零生产调用方):

  agent-node/src/peer-reply-task-trace.ts     只被 test682 的两个文件 import
  agent-node/src/runtime/reply-routing.ts     只被自己的 reply-routing.test.ts import
                                              导出的 ReplyRoute = "send_reply" | "send_task"
                                              正是 #698 废掉的那个概念

核过的边界:

- task-trace.ts 不受影响 —— 它另有 4 个生产引用者(channel-task-trace /
  client-task-trace / commhub-mcp / explicit-task-trace)。
- reply-routing-source.test.ts 保留:它那句 not.toContain 是反向断言,
  锁住老形状不许回来,和被删的模块无关。
- 删后全仓不再有对这三个文件的引用(负向断言里的字符串字面量除外)。
- agent-node/src 测试文件数 91 → 90。

Co-authored-by: vansin <smartflowaiteam@gmail.com>
… 条告警) (#842)

* chore(deps): agent-network lockfile 把 hono 推过修复线(4.12.25 → 4.13.1)

关 #840。

agent-network/package-lock.json 把 hono 钉在 4.12.25,Dependabot 有 6 条 open
告警指向它,全部挂在这个 manifest 上:

  #106 medium  修复于 4.12.34   memo() 跨请求残留 SSR 输出
  #105 low     修复于 4.12.34   Proxy Helper 不清 Connection 列出的响应头
  #104 medium  修复于 4.12.34   Language 中间件算法复杂度 DoS
  # 60 medium  修复于 4.12.27   cx() 的 JSX 转义绕过导致服务端 XSS
  # 59 medium  修复于 4.12.27   API Gateway v1 adapter 丢重复响应头
  # 58 medium  修复于 4.12.27   hono/jsx 不按请求隔离 context

(我开 #840 时只列了前三条,漏了 #58/#59/#60 —— 它们创建于 2026-07-24,
修复线更低。已在 issue 里更正。)4.13.1 高于两条修复线,6 条全覆盖。

🔴 这不是安全修复,别在 release notes 里写成安全修复。两条理由:

1. 实际暴露面为零。这些告警分别需要 hono/jsx 的 memo() / cx() / per-request
   context、hono/proxy、hono/language、API Gateway adapter,而仓里 576 个
   tracked .ts/.tsx 对 hono 零引用(大小写不敏感)——它是经
   @modelcontextprotocol/sdk → hono ^4.11.4 传递进来的,没有任何一行代码用它。
2. lockfile 不随 npm 包发布。消费者 npm i 时重新解析,所以这个改动不改变
   已发布包的用户拿到的依赖,只影响本仓与 CI 的构建。

改动刻意做成最小:npm update hono --package-lock-only,不整体刷新 lockfile。
实测波及范围:

  版本变化 = 1  新增 = 0  移除 = 0
    hono  4.12.25 → 4.13.1
  package-lock.json | 6 +++---   (3 insertions, 3 deletions)

308 个包里只有它一个动了。

验证:tests/test745-agent-network-unit-ci(它用 npm ci 且 COPY lockfile,
所以改动会真正生效)
  438 pass  0 fail  executed_files=46 discovered_files=46
  MUTATION_RED stale-config-help rc=1
  RESULT: PASS  退出码 0

* docs(tests): 刷新 test745 报告 —— 记录新锁的 hono 跑绿的那次

审查(#842)指出:这个 PR 改了 agent-network/package-lock.json,而 test745 用
npm ci 装依赖 —— 改动改变了这道门实际跑的依赖图,而报告仍记着 b4e13f4 那版
镜像。仓里因此没有新锁制品的留存证据。指控成立。

新增一节,记录 source 507bae6 那次:
  image id sha256:ac8b956a…
  镜像内读回 TEST745_SOURCE_COMMIT=507bae6f…
  镜像内实装 hono = 4.13.1     ← 这是本次改动的主张本身
  438 pass / 0 fail / executed_files=46 discovered_files=46
  MUTATION_RED stale-config-help rc=1  RESULT: PASS 退出码 0

hono 版本那一步不是凑数:套件全绿不证明 lockfile 生效 —— 构建缓存没失效、或
Dockerfile 没 COPY lockfile,都会给出一模一样的 438 绿。

建门那次的记录整段保留为附录。

自评:同一条审查意见我一小时前刚在 #841 上收到并修复,却没把同一个检查用到
同一次会话里创建的这个 PR 上 —— 修了实例,没修类。已对我全部 open PR 做了一遍
审计:改了套件输入且零报告更新的,只有这一个。

---------

Co-authored-by: vansin <smartflowaiteam@gmail.com>
…800)

* test(ci): 让 test725/test745 覆盖 tests/ 目录,兑现"complete unit domain"

两个门的抬头都写着 "complete agent-node/agent-network unit domain",
但只跑 src/,把 tests/ 下 25 个文件排除在外 —— 其中不乏安全相关的:
feishu-markdown-image-ssrf、secret-mask ×3、vendor-error-sanitize、feishu-tool-deny。
这些正是静默失效代价最高的那类。

这个目录里混着两种测试,任何单一命令都跑不全:
- 脚本式(16+6 个):自己打 "N/N passed",失败 process.exit(1),必须 bun <file>;
  用 bun test 跑会因为 top-level 的 process.exit 把整个 run 打断在第一个文件
  (实测:bun test tests/ 只跑完第一个就结束)。
- bun:test 式(3 个):describe/it,必须 bun test <file>;用 bun <file> 跑会报
  "Cannot use describe outside of the test runner"。
所以按文件内容分派,并把两条判据都写进注释。

退出码可用已先验:这些脚本失败时确实 process.exit(1),不是 fail-open。

落地前实测:
  agent-node/tests   6/6 直接过
  agent-network/tests 单命令 14/19 → 按内容分派 17/19 → 补两处环境契约 19/19
两处契约都在 Dockerfile 内解决,并写明原因:
  - feishu-envelope-compat 跨包 import agent-node/src/runtime/feishu-envelope
  - feishu-bridge-ipc 硬编码绝对路径 /work/feishu-attachments,容器里 node 建不了

分母承重:tests_dir_executed 必须等于 find 出来的数,且 >0。

实测:test725 tests_dir 6/6/0 + MUTATION_RED + PASS;
     test745 tests_dir 19/19/0 + MUTATION_RED + PASS。

* test(ci): test725 的 mutation 命名断言也锚在 (fail) 行

与 #798 同一类:原来 grep 的
'the inbox choke point feeds the augmented text into processTask'
是**测试名**,而 bun test 对每个用例都打 `(pass) <名字>` / `(fail) <名字>` ——
那条用例通过时也会命中,断言只证明了「它存在」,不证明「红落在它身上」。

A/B 在 #798 上做过(把断言指向一条该 mutation 下不会红的用例):
  松版 → rc=0 RESULT: PASS(收下了不合规);严版 → rc=1 点名失败。

这道门不是我写的,我在本 PR 里本来就在改它的 run.sh(加 tests/ 分派),
所以顺手收紧;改动只让门更严,并在下面重跑验证仍绿。
若 owner 认为不该由本 PR 动它,我可以拆出去。

* test(ci): tests/ 分派也要绝对下限,同 #798 那个洞

#798 实测:只比「executed == discovered」的门,在删掉 85% 测试文件后
照样 RESULT: PASS —— 分母跟着现实自动缩水。

我在本 PR 里加的 tests/ 分派用的是同一形状,所以有同一个洞。
补 AGENT_NETWORK_TESTS_FLOOR=15(现 19 个)、AGENT_NODE_TESTS_FLOOR=5(现 6 个),
并写明「真删了测试就故意改这个数」。

范围说明:只给**我在本 PR 新加的 tests/ 分派**加下限,没有动这两个门原有的
src/ 分母判定(那是 #791/#725 的既有代码,同类下限缺失我另报,不夹进本 PR)。

* docs(tests): report-only —— 锚点 1e9e75d,含下限的双向验证

---------

Co-authored-by: vansin <smartflowaiteam@gmail.com>
Co-authored-by: t <t@x>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* test(ci): 给 server 补上聚合单测门(69 个单测此前 CI 只跑 6 个)

server/src 下 69 个 *.test.ts,CI 可达的只有 6 个(scripts/qa.sh 的 L0_TESTS
点名 5 个 + test686 引用 1 个),另外 63 个没有任何 job 会碰。server 是 hub 本体
—— 认证、token、网络隔离都在这里,盲区比 agent-network 那 46 个严重。

形状抄 test745/test725,但按 server 自己的契约做了两处改动:

1) 逐文件跑,每个文件一个独立 DB。scripts/qa.sh 的 L0 本来就是
   `COMMHUB_DB=/tmp/qa-l0-$name.db bun test <one-file>` —— 这是既有契约。
   用一个共享 DB 聚合跑会红 4 条(admin-networks 的 global-admin 可见性、
   scheduled-tasks 三条),而这 4 条单跑全绿,是跨文件状态污染。
   把"聚合能不能跑"当门等于给它加了一条它从没承诺过的性质。

2) cwd 必须是仓根。task-lifecycle-watcher 用 process.cwd() 拼
   ./server/src/db.js,scheduled-tasks-http 按仓根相对路径 import
   tests/test601-.../race-worker.ts。从 server/ 目录跑会让这两个红在路径上,
   看起来像产品坏了。

红线:COMMHUB_DB 不设默认指向生产库。容器里够不到宿主的库,但不靠"够不到"
保证 —— run.sh 显式钉到 /tmp 并断言钉住了。31/69 个测试引用 sqlite/COMMHUB_DB。

分母承重:executed_files 必须等于 find 出来的 test_files,少一个就红。

witnessed-red:把 auth.ts 注册密码下限 `< 8` 改成 `< 1`(7 位密码会被接受,
一条真的安全回退),先校验字节非 no-op,再要求红落在指名的
"rejects 7-char password" 上。

实测:test_files=69 executed_files=69 failed_files=0,
MUTATION_RED registration-password-floor-weakened rc=1,RESULT: PASS,耗时 51s。
完整输出见 docs/tests/report-test798-server-unit-ci.txt。

* ci: server 单测门抽成独立 job,别挂在 agent-network 名下

上一版把 build/run 两步插进了 agent-network-unit job 里,所以它确实跑了
(CI 日志实测 test_files=69 executed_files=69 failed_files=0
MUTATION_RED registration-password-floor-weakened rc=1 RESULT: PASS),
但会以 "agent-network unit (Docker, non-root)" 的名义显示 ——
server 挂了会归错帐,而且两个重 Docker build 串在一个 job 里。

抽成 server-unit job,显示名 "server unit (Docker, non-root)"。

* test(ci): 让 test725/test745 覆盖 tests/ 目录,兑现"complete unit domain"

两个门的抬头都写着 "complete agent-node/agent-network unit domain",
但只跑 src/,把 tests/ 下 25 个文件排除在外 —— 其中不乏安全相关的:
feishu-markdown-image-ssrf、secret-mask ×3、vendor-error-sanitize、feishu-tool-deny。
这些正是静默失效代价最高的那类。

这个目录里混着两种测试,任何单一命令都跑不全:
- 脚本式(16+6 个):自己打 "N/N passed",失败 process.exit(1),必须 bun <file>;
  用 bun test 跑会因为 top-level 的 process.exit 把整个 run 打断在第一个文件
  (实测:bun test tests/ 只跑完第一个就结束)。
- bun:test 式(3 个):describe/it,必须 bun test <file>;用 bun <file> 跑会报
  "Cannot use describe outside of the test runner"。
所以按文件内容分派,并把两条判据都写进注释。

退出码可用已先验:这些脚本失败时确实 process.exit(1),不是 fail-open。

落地前实测:
  agent-node/tests   6/6 直接过
  agent-network/tests 单命令 14/19 → 按内容分派 17/19 → 补两处环境契约 19/19
两处契约都在 Dockerfile 内解决,并写明原因:
  - feishu-envelope-compat 跨包 import agent-node/src/runtime/feishu-envelope
  - feishu-bridge-ipc 硬编码绝对路径 /work/feishu-attachments,容器里 node 建不了

分母承重:tests_dir_executed 必须等于 find 出来的数,且 >0。

实测:test725 tests_dir 6/6/0 + MUTATION_RED + PASS;
     test745 tests_dir 19/19/0 + MUTATION_RED + PASS。

* docs(tests): report-only —— 锚点 46e752c(含 current main 034f006)

按独审要求重做 provenance:append current main → 在精确源码提交上重跑 → report-only 子提交。

main 的新增提交 #802 只动 tests/qa-180-rename-ghost/,与本 PR 四个文件零相交,
rebase 无冲突;qa.yml 两处 path(test746 / test798)都保留;
server 步骤已是独立 job server-unit(name="server unit (Docker, non-root)", timeout 12),
不再嵌在 agent-network-unit 里。

实测:executed_files=69 discovered_files=69 failed_files=0,
MUTATION_RED registration-password-floor-weakened rc=1,RESULT: PASS。

* docs(tests): report-only —— 锚点 a4fd375(含 current main 034f006)

按独审 SUPERSEDE 的要求重做 provenance。原 CLEAN 判定被撤回是对的:
上一份报告声称的锚点 92d9612 比本 PR base 还早两个提交,那上面的 run.sh 里
没有 tests_dir_executed 那段代码,报告内容 provably 不可能由它产出。
根因是我跑门时 --build-arg SOURCE_COMMIT 传的是当时的 origin/main。

实测:test725 tests_dir 6/6/0 + MUTATION_RED + PASS;
     test745 tests_dir 19/19/0 + MUTATION_RED + PASS。

* ci: 元门 —— 修掉独立审抓出的三条 P1(其中一条是元门自己的漏网)

独立审(codex)在本 PR 上提了三条 P1,逐条复现后全部成立:

1) **深度不感知 —— 元门自己放行了没人会跑的测试。**
   两个 unit runner 扫 `<pkg>/tests` 用的是 `find … -maxdepth 1`,而本脚本
   原来只按前缀判覆盖。复现:把一个测试放到 `agent-network/tests/sub/` 下,
   元门报「0 个漏网」rc=0,而 runner 的 find 对它命中 0。
   **这正是这道门存在的意义所在,它却在自己身上漏了。**
   修法:深度从门里推导(scan_depth),不假定递归;`bun test <dir>/` 形式按递归算。
   双向验过:子目录文件 → rc=1 且点名;直属文件 → rc=0。

2) **套件豁免不校验套件是否真实存在。**
   原来只要路径以 `tests/` 开头就放行,于是 `tests/test999-example/new.test.ts`
   这种既没 Dockerfile 也没 run.sh 的目录也能过 —— 豁免变成「只要放对地方
   就不用被任何东西跑」。改成要求套件目录里 Dockerfile 和 run.sh 都在。
   双向验过:伪套件 → rc=1;补上两个文件 → rc=0。

3) **qa.yml 改动不触发本门。**
   qa.yml 决定那三个聚合门到底跑不跑,它一改本门的前提就可能塌,
   但它不在触发路径里。已加进两处 path 过滤。

NOT COVERED(第 2 条修完仍存在的缺口):校验了「套件是一套门」,
但**没有**校验「该套件已注册进 CI」。test224/test597/test679 就长期
有完整 Dockerfile+run.sh 却没人跑 —— 那是 #803 在解决的问题,不是本门的判据。

* ci: 元门要验「这道门真的被 CI 跑」,不只是「它存在且声明了范围」

独立审(codex P1)指出的缺口,我上一版只在 NOT COVERED 里记了没修:
qa.yml 一旦删掉/改名某个 job、或不再 build/run 它的 Dockerfile,
本脚本照样发绿 —— 因为它从没看过 qa.yml。
**这正是本门要防的那类问题(有门、没人跑),不能留在自己身上。**

判据要求 qa.yml 里同时出现两件事,单独一条不算:
  -f tests/<suite>/Dockerfile        真的构建了它
  docker run … <这次 build 打的 tag>  真的跑了那个产物

两条解耦 mutation,各自红在不同原因上(基线绿):
  F1 删掉 server-unit 的 docker run(build 保留)
     → rc=1「qa.yml 构建了 anet-test798-server-unit 但没有 docker run 它」
  F2 把 test745 的 build -f 路径改名
     → rc=1「qa.yml 里没有 build tests/test745-agent-network-unit-ci/Dockerfile」

一道门可能覆盖多个根(test745 覆盖 src 与 tests),接线问题去重后只报一次。

* docs(tests): report-only —— 锚点 9626c98,七条 mutation

* ci: 落实 ⑤⑥ 两条已接受未实施的意见;② 需所有者决定,如实标注

⑥ SOURCE_COMMIT 只验格式不验字节
   原来只验 ^[0-9a-f]{40}$。任何 SHA 都能过,而审查指出提交进来的 report
   里那个 SHA 早于本套件自身 —— 那份证据无法从它自称的版本复现。
   改成与 test823 相同的做法:构建时把 run.sh 在该 commit 下的 git blob
   哈希作为 build-arg 传入,容器内就地重算比对(blob 哈希 =
   sha1("blob <len>\0"+内容),不需要容器里装 git)。
   已验脚本内算法与 git hash-object 结果一致;该机制的端到端红/绿在 #835
   上证过两次(传错 blob、blob 对但文件被篡改,都 exit 1)。

⑤ qa.yml 缺 test601 路径
   test798 的镜像 COPY 了 test601 的 race-worker.ts,而
   server/src/scheduled-tasks-http.test.ts 会执行它做「两个真 Hub 抢同一
   occurrence」。只改那个 worker 的 PR 不该跳过这道门。已在两处 paths 补上。
   (这 4 行原本只存在于 #798;若只合本 PR、把 #798 当冗余关掉,它们永远
   不会落地 —— 此前已在本 PR 记录过这个坑。)

② server 的 npm install 无 lockfile —— 我没有改,需要所有者决定
   实测:server/package.json 有 4 个依赖,4 个全用 caret 范围,且仓里没有
   任何 lockfile/shrinkwrap。所以同一个 commit 在不同时间构建确实会解析出
   不同依赖图,审查这条成立。
   但修法只有一条:提交一份 lockfile。那是仓库级的依赖钉死决策 —— 它影响
   每一次 server 构建,不只是这道门;而且生成出来的树我无法在这里验证是否
   仍然全绿。这不该由我单方面决定,如实留作待决,不假装已修。

* ci(test798): server 依赖钉死 —— 提交 lockfile 并改用 npm ci

审查 ② 说的成立:server/package.json 4 个依赖全用 caret 且仓里没有 lockfile,
所以同一个 commit 在不同时间构建会解析出不同依赖图 —— 上游发一个兼容版本
就能让这道门变红或改变被测行为,而仓库一个字节都没动。

我上一版把这条标成"仓库级决策,不该由我单方面做"。那个定性是错的:
  agent-network/package-lock.json              已提交
  docs-site/package-lock.json                  已提交
  prototype/anet-client-app/package-lock.json  已提交
5 个包里 3 个已经提交 lockfile,.gitignore 的 *.lock 也匹配不到
package-lock.json。提交它是本仓既有做法,server 与 agent-node 只是不一致。
真正卡住的是一次验证跑,不是授权 —— 我把成本问题说成了权限问题。

本次改动:
- npm install --package-lock-only 生成 server/package-lock.json(1212 行,
  未装 node_modules)。锁到的直接依赖:
    @modelcontextprotocol/sdk 1.30.0 / bun-types 1.3.14 / hono 4.13.1 / zod 4.4.3
- Dockerfile 改为 COPY package.json + package-lock.json,并把 npm install
  换成 npm ci(ci 严格按 lockfile 装,install 会按 caret 取"当下最新兼容版")。

验证:带 lockfile 重建后跑完整套件
  test_files=69  executed_files=69  failed_files=0
  MUTATION_RED registration-password-floor-weakened rc=1
  RESULT: PASS  退出码 0

* ci(test798): 把 RUNSH_BLOB 真的传进去 —— 门在要求它,workflow 从没供给

CI 上 `server unit (Docker, non-root)` 稳定红,日志里唯一的失败行:

  FAIL: TEST798_RUNSH_BLOB 缺失或格式不对 —— 无法把 SOURCE_COMMIT 绑到被测字节

链条断在最后一环:

  run.sh:25-27      要求 TEST798_RUNSH_BLOB 且校验 ^[0-9a-f]{40}$,否则 fail-closed
  Dockerfile:41,45  ARG RUNSH_BLOB → ENV TEST798_RUNSH_BLOB
  qa.yml:79-84      docker build 只传 SOURCE_COMMIT,**没传 RUNSH_BLOB**

于是 ARG 取空、ENV 为空串、正则不过。门本身是对的 —— 它正确拒绝了一次
「说不清自己测了哪份字节」的运行,缺的只是供给那一行。

补法用 git 自己的 blob 哈希,和 run.sh:31 的算法是同一个东西:

  run.sh 算的是 sha1("blob <len>\0" + 内容),那正是 git 的 blob object id。

本地实测两者一致(在本分支 head 上):

  git rev-parse HEAD:tests/test798-server-unit-ci/run.sh
    = 0e48c36
  { printf 'blob %d\0' "$(wc -c < run.sh)"; cat run.sh; } | sha1sum
    = 0e48c36

对照 #798:它的 run.sh 里 RUNSH_BLOB 命中 0 次 —— 所以这不是 #798 的回归,
是本 PR 新加的要求没接完线。

🔴 这一条只修 CI 红。独立审查另指出本 PR 仍夹带 #798 的旧版本、需在 #798 之后
rebase —— 那件事不在本提交范围内。

---------

Co-authored-by: vansin <smartflowaiteam@gmail.com>
Co-authored-by: vansin <t@t>
Co-authored-by: t <t@x>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* docs: docs/ 里 13 条 cli.ts 行号引用改钉符号锚(22 → 9)

#852 量过:docs/ 下按 blob/main 钉 cli.ts 行号的引用,锚文本带符号名、可机器判定的
11 条里 **漂移 11、仍对 0**。这次把能确定唯一锚串的都改掉。

改法照 #845 已确立的形状:

  改前  [`cli.ts:228 loadProfile`](…/blob/main/agent-network/bin/cli.ts#L228)
  改后  [`cli.ts`](…/blob/main/agent-network/bin/cli.ts) —— 搜 `function loadProfile(`

13 条的真实位置(改之前它们全都指错了):

  adminUtokPath        文档说 28      实际 138
  saveGlobal           文档说 77-81   实际 1000
  saveServerConfig     文档说 89-95   实际 1047
  saveAdminUtok        文档说 105-111 实际 1062
  loadProfile          文档说 228     实际 1208
  saveProfile          文档说 246-273 实际 1272
  setupCommand         文档说 556     实际 1860
  ensureMcpJson        文档说 1644    实际 4300
  runCommand           文档说 2044    实际 5641
  renameCommand        文档说 2583 / 2629  实际 6911
  deleteCommand        文档说 2800-2840    实际另处
  dashboardReleaseTag  文档说 347          实际另处

每一条新锚串都逐条核过在 cli.ts 里**唯一**(13 条,非唯一 0 条)。
`RuntimeName` 那条**没有改**:它在 cli.ts 里出现 13 次,做不出唯一锚 —— 与其钉一个
含糊的锚,不如留着行号,等有人给它一个能唯一定位的写法。

剩下 9 条锚文本里没有符号名(形如 `[cli.ts:1724](…#L1724)`),机械改不了,
需要人读源码判断它当初想指的是什么。留给 #852。

* docs: 再修 6 条需要人读源码的 cli.ts 引用(9 → 3)

接上一提交。这 6 条的锚文本里没有符号名,机械改不了,是逐条读上下文判出它当初
想指什么、再去源码里定位的:

  node-lifecycle.md:206  正文说 notifyServerOffline  → 搜 `async function notifyServerOffline(`
  node-lifecycle.md:213  正文说「确认流程」          → 搜 `This will delete "${displayName}" (node_id:`
  node-lifecycle.md:383  正文直接写了 resolveNodeRef → 搜 `function resolveNodeRef(`
  architecture.md:316    正文说写 .mcp.json          → 搜 `.mcp.json: commhub → .anet/node-server.js`
  architecture.md:326    正文引了 compare-by-content → 搜 `if (src !== dst)`
  architecture.md:328    RuntimeName type            → 🔴 它已经不在 cli.ts 里了

最后一条值得单说:文档写「RuntimeName type cli.ts:145」,但 cli.ts 里
`type RuntimeName =` 出现 **0 次** —— 这个类型已经搬到
`agent-network/src/normalize-runtime.ts:16`。这不是行号漂移,是文件都换了。
所以这条改的是链接目标,不只是锚。

「确认流程」那条要小心:`Run again with --force to confirm.` 在 cli.ts 里出现 **2 次**
(deleteCommand 8143 / networkCommand 10173),不能拿它当锚。读上下文确认文档说的是
节点删除,才选了 deleteCommand 里唯一的那句。

每条锚串都核过唯一。node-lifecycle.md 的行号 pin 已归零。

* docs: 收掉最后 3 条 cli.ts 行号引用 —— docs/ 归零(22 → 0)

这 3 条的锚文本里没有符号名,是逐条读正文判出来的:

  architecture.md:520  正文自己就写着「`sub === "dashboard"` 分支」
                       → 链接去掉行号即可,锚已经在正文里(唯一,cli.ts:6092)
  RFC-002:37           「参考实现」指的是 anet channel add/ls
                       → 搜 `async function channelCommand(`(唯一,cli.ts:8159)
  pitfalls.md:80       「candidates 数组:源文件搜索顺序」
                       → cli.ts 里 `const candidates` 有 4 处,其中 1024 与 4333
                         都是 node-server.js 解析顺序。判据是 4333 那处的注释
                         `// dist/src/node-server.js(npm 包混淆后产物,优先)`
                         与 pitfalls 正文第 1 条逐字对应,1024 那处不是。
                       → 搜那条注释(唯一)

至此 docs/ 下 blob/main 形式的 cli.ts 行号引用为 0。每条锚串都核过在源码里唯一。

---------

Co-authored-by: t <t@t>
Co-authored-by: t <t@x>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
#857 把 docs 里的行号 pin 换成了符号锚点。换得对:那 13 条行号逐条对下来
**13 条全错** —— `loadProfile` 实际在 cli.ts:1274(doc 写 228),`runCommand`
在 5812(doc 写 2044),`ensureMcpJson` 指的那一行是**空行**。而它们全都长得像
有效引用:格式对、行号在文件范围内、点开能打开,所以读的人不会怀疑。

但换完之后留了一格没人看:**符号锚点不会因为「上面插了几行」失效,却会因为
改名而失效,而失效之后同样没有任何东西会喊。** #843 那道门在数行号 pin(守住
不再变多),符号锚点在变多,一直没有对应的门。

判据:每一条 `搜 \`X\`` 里的 X,必须在**它左边最近的那个源码链接**指向的文件里
逐字存在。两类失败都报 —— 找不到(改名/删除/写错),以及前面根本没有链接
(无法判定它指哪个文件,这本身就是缺陷)。

## 起点(与手工核对一致)

    checked 21 symbol anchor(s) across 262 tracked doc(s); 21 resolved
    every symbol anchor exists in the file it names.

21 这个数是先手工数出来的,再让脚本跑 —— 两边对上才用。

## 见红(三种坏法,三种输出,互不相同)

    A 改一个锚点为源码里不存在的名字   → rc=1  "not found in 'agent-network/bin/cli.ts'"
    B 去掉锚点前面的链接               → rc=1  "no source link precedes this anchor"
    C 把扫描范围改成一个不存在的目录   → rc=2  "0 tracked .md … 扫描范围塌了"

三份输出的 md5 两两不同。C 是分母承重:🔴 这道门最可能的坏法不是判据写错,
是**一条都没扫到然后打印一片绿** —— 那种假绿和真绿逐字相同。所以扫到 0 个 md
或 0 条锚点一律 exit 2,让「没问题」和「没有看」在输出上长得不一样。

变异做完全部还原:`cmp docs/architecture.md` 与 main 逐字节相同。

## 🔴 刻意不加 paths 过滤

这道门的主要失效场景是**有人在 cli.ts 里把一个函数改名**,不是有人改了 doc。
按 `docs/**` 过滤的话,改源码的 PR 不会触发它 —— 门在、判据也对,但在最需要它
的那一类改动上永远不会被触发。整个脚本跑完不到一秒,省这点没有意义。

`--selftest` 8 条,含「无锚点文本 → 计数必须为 0」。job 名 `doc-symbol-anchors`
全仓唯一(25 个 job,25 个不同的名字)。

Co-authored-by: t <t@x>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
changelog:713 的两条引用按 blob/main 钉行号,现在都已经指错了 —— 因为没有越界,
所以 #834 那种「文件行数 vs 引用行号」的判据抓不到它们。

  cli.ts:61   声称是 PINNED_SERVER_VERSION → main 上真实在 791 行,
                                             61 行现在是 } from "../src/opencode-preset";
  cli.ts:2589 声称是 bunx commhub-server 启动点 → 真实在 5765 行,
                                             2589 行现在是 opencode auth-login 的帮助文本

钉到 3a38720(2026-05-17),不是修复提交 4d24024,理由:

  两个提交上 L61 / L2589 都精确命中,但 3a387204d24024 的父提交,
  它的 PINNED_SERVER_VERSION 值是 "0.8.0" —— 正是正文描述的那个 bug 状态
  (「仍 hardcode 0.8.0」「实际 bunx --bun @sleep2agi/commhub-server@0.8.0」)。
  钉修复后那次会让链接显示 0.8.2,和正文对不上。

3a38720 是 origin/main 的祖先,blob 链接实打 HTTP 200。

Co-authored-by: vansin <smartflowaiteam@gmail.com>
Co-authored-by: t <t@x>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
changelog 中英两处都指向 blob/main/server/src/index.ts#L253。这个链接
现在指到一个只有 15 行的文件的第 253 行 —— 因为 #438 把 index.ts 改成了
run-entry shim,真实代码搬到了 server.ts:

  // Run-entry shim (#438 corrective).
  // All real code lives in ./server.ts …

改钉 22ed188(写这条 changelog 的那次提交)。核过:那时 index.ts 有
1623 行,:253 正是这条 changelog 描述的 disk 告警逻辑
(disk_avail_gb < 1 → red),:253-326 区间里 disk_*_gb 出现 6 次。

病因不是"行号漂了",是 ref 选错了:changelog 条目描述的是一个冻结的
历史时刻,却指向会移动的 main —— 这样的链接必然烂,而且是无声地烂。
换成当时的 SHA 之后,它永远成立。

这与参考页(api/rest.md 等)的修法不同:那边该改钉符号,因为它描述的是
"现在的行为";changelog 描述的是"当时发生了什么",该钉当时的 commit。
把两者混为一谈会修错 —— 给 changelog 更新行号,下次重构又坏。

详见 #831。

Co-authored-by: vansin <smartflowaiteam@gmail.com>
Co-authored-by: t <t@x>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* ci(qa): 给 L1 加并发上限,默认 nproc,可用 QA_L1_MAX_PAR 覆盖

L1 原来是无节制后台化:L1_TESTS 有多少条就同时拉起多少个容器(当前 17 条)。
在专用 CI runner 上没问题;在开发/生产共用的机器上不行 ——
实测本机(8 核,同时跑着生产 hub、dashboard 与约 200 个 agent session)
一次 `qa.sh --l1` 把 load1 顶到 58,即 7.3x 超订;跑完回落到 30 一线。

默认上限取 `nproc`(不是更激进的 nproc/2),要同时满足两件事:
小核 CI runner 上尽量不改变现有耗时,大核共享机上把超订压下来。
`QA_L1_MAX_PAR=0` 表示不限,可完全恢复旧行为。

## 第一版是坏的,靠实测才发现

最初写的闸门是:

    while … (( $(jobs -rp | wc -l) >= QA_L1_MAX_PAR )); do sleep 0.2; done

**它从不阻塞。** `$( )` 会开子 shell,而 `jobs` 的作业表不跨子 shell 继承,
所以那个计数恒为 0。实测坐实:上限设 2,`docker ps` 采样到的 anet-* 容器峰值仍是 **3**。

改成在父 shell 里用 `kill -0` 数活着的 pid 之后:

    上限 2 → 采样峰值 **2**(分布:0×6、1×3、2×29),`ALL PASS in 40s`

两版用的是同一组 4 个套件、同一台机器、同样的采样方式(每秒数一次
`docker ps --format '{{.Image}}' | grep -c '^anet-'`),只差闸门实现。

## NOT COVERED

- **没有测 CI 上的墙钟影响**。GitHub runner 核数少,默认上限会等于那个核数,
  与现在的 17 路并发不同。当前 L0+L1 job 实测用 141–148s / 预算 300s,
  我无法在本地可靠复现 runner 的时序 —— 若复核认为有风险,
  可以在 workflow 里显式设 `QA_L1_MAX_PAR=0` 保持旧行为,或设一个更大的值。
- 只限制了 `docker run` 的并发;**build 仍是串行**,未改。

* ci(qa): 并发上限的值必须先校验 —— 否则一个笔误就静默恢复无上限

闸门条件是 `[[ "$QA_L1_MAX_PAR" -gt 0 ]]`,而 bash 在算术上下文里把非数字
当 0 —— 0 的语义恰好是「不限」。于是 QA_L1_MAX_PAR=two / =4x 会**静默恢复
本 PR 要消除的无上限行为**,同时那行 note 还照打「L1 并发上限 = two」,
输出主动确认了一个并不存在的上限。

这条不是理论问题:无上限时实测本机 load1 顶到 58(8 核,同时跑着生产 hub、
dashboard 与约 200 个 session)。fail-open 的方向正好是压垮共享机那一边。

改成 fail-closed:值不是非负整数就退回默认并大声报出来。

另修一个我自己第一版没料到的洞 —— 全数字还不够:bash 把前导零当八进制,
`[[ "08" -gt 0 ]]` 报 `value too great for base` 并返回非零,闸门照样失效;
`010` 则会被静默当成 8。加 `$((10#…))` 规范化。这个洞是跑对照表时发现的
(用例里放了 08),不是想出来的。

对照(旧 → 新):
  '4'    开        → 4  开
  '0'    不限      → 0  不限        (有意保留)
  'two'  不限      → 8  开 + 告警
  '4x'   不限      → 8  开 + 告警
  ' 3'   开        → 8  开 + 告警
  '08'   不限+报错 → 8  开
  '04'   开        → 4  开

* Merge origin/main into #823 + 更正一句注释的机制描述

结论和修法都对,合。但注释里的机制说法我复核后不准确,顺手改准。

原文:「命令替换会开子 shell,而 `jobs` 的作业表不跨子 shell 继承,
        那样数出来**恒为 0**、闸门形同虚设。」

实测(bash 5.2.21,非交互脚本,MAX=2,启 8 个后台任务):

    jobs 口径采样序列: 0 1 1 1 0 1 0 1
    kill-0 口径峰值   : 2

**不是恒 0,是从来到不了上限值** —— `(( n < MAX ))` 因此永远为真,闸门永远放行。
坏的结果一样(PR 里实测「上限设 2、docker ps 峰值 3」是对的),但机制不同。

为什么值得改这一句:下一个人照「恒为 0」去排查,一跑发现是 1,
很可能得出「这条注释过时了/不适用了」,从而把 `jobs` 版本改回来。
**一个正确的结论配一个编错的机制,会把下一步的动作导向错的地方。**

同时把另外两条陷阱各自实测确认(它们的描述是准确的):

    [[ two -gt 0 ]] → 假  ⇒ while 不进入 ⇒ 无上限(非数字在算术上下文当 0)
    [[ 08  -gt 0 ]] → bash: [[: 08: value too great for base ⇒ rc=1 ⇒ 同样静默失效
    $((10#08)) = 8

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: vansin <smartflowaiteam@gmail.com>
Co-authored-by: t <t@x>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
vansin and others added 4 commits August 18, 2026 08:27
两个 checker 用同一个正则从 `scripts/qa.sh` 里取数组:

    re.search(rf"{name}=\(([^)]*)\)", text, re.S)

`[^)]*` 在**第一个** `)` 处停下。而 bash 数组里注释是合法的,注释里出现 `)`
也是合法的。#835 往 L1_TESTS 顶部加了一行:

    L1_TESTS=(
      # (注册这一步不是可选的 —— 一个没被任何东西调用的套件等于不存在。)
      "test823-l1-concurrency-cap"
      ...

正则在那个 `)` 处截断,捕获内容里**一个套件名都没有**。

🔴 **判据完全正确,塌的是取集。**

## 两道门吃同一个洞,表现完全不同 —— 这才是要紧的部分

把那行注释注入 `origin/main` 的 qa.sh,A/B 跑:

| checker | 未修复 | 修复后 |
|---|---|---|
| `check-l1-paths-sync.py` | **exit 2**「found no L1_TESTS entries — parse regression, refusing to pass」 | rc=0,17 个套件 |
| `check-qa-trigger-coverage.py` | **rc=0**,`CI-executed: 7` | rc=0,`CI-executed: 11` |

**第二个静默判绿,并且把 11 个套件悄悄算成了 7 个** —— 少的正是
test686 / test746 / test765 / test766(它们只出现在 L1_TESTS 里)。
它照常打印「all 7 CI-executed test directory/ies can re-trigger qa.yml」,
**一句真话,建立在一个塌掉的分母上。**

同一个 bug,一个 fail-closed 所以被看见,一个 fail-open 所以不会。
**看见它的那个救了另一个** —— 否则 trigger-coverage 会带着 7/11 的分母
一直绿下去,而它存在的全部意义就是那个分母。

## 修法

加 `_strip_comments()`:按行剥掉 `#` 之后的内容再匹配。数组元素是 kebab-case
的套件名,不含 `#`,按行剥是安全的。

## 见红

selftest 加两条夹具(照着实际撞红的那行写),然后把 `_strip_comments` 变成
恒等函数(`return text`)验证它们真的守着这件事:

    正常:  selftest: 10/10 ok                     rc=0
    变异:  FAIL 注释里的 ) 不截断数组  → 1 case(s) rc=1

另一条「数组后面别处的 `)` 不影响」用来钉住剥注释没有把范围放宽。

Co-authored-by: t <t@x>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
冲突两处。

## 一、build-arg 推导:两边各解决一半,取并集

  main 侧:从**套件自己的 Dockerfile** 读 `ARG (SOURCE_COMMIT|TESTNNN_SOURCE_COMMIT)`
          —— 权威,不靠套件名猜;但只取 `head -1`,**不供给 RUNSH_BLOB**
  #835 侧:按套件名推导并**两套命名都传** + 供给 RUNSH_BLOB + git 调用非致命

哪一边都不能单取:

  - 只取 main:test823 的 Dockerfile 要 `SOURCE_COMMIT` **和** `RUNSH_BLOB`,
    后者拿不到 → 套件在自检第二步 fail-closed,本 PR 的门自己起不来;
  - 只取 #835:回退到按名字猜,而 test224/test597 那种不带前缀的 `ARG SOURCE_COMMIT`
    是名字推导表达不了的形状。

并集 = main 的 Dockerfile 推导 + 「Dockerfile 声明了 ARG RUNSH_BLOB 才供给」+
#835 的非致命 git。

🔴 非致命 git 不是防御性编程,是硬需求:test823 会在一个**只装了
bash/coreutils/procps、没有 git** 的容器里重放这个脚本(它桩了 docker 和 npm,
没桩 git)。直接 `$(git rev-parse HEAD)` → 127 → `set -e` 当场中断 →
docker 桩一次都没被调用 → 峰值恒为 0 → **闸门自己的回归"通过"得毫无意义**。

逐套件核过推导结果(18 个 L1 套件):

    test823-l1-concurrency-cap   --build-arg SOURCE_COMMIT=… --build-arg RUNSH_BLOB=…
    test686 / test765 / test766 / test746   --build-arg TESTNNN_SOURCE_COMMIT=…
    其余 13 个 qa-*               (不传)

**只有 test823 多拿一个 RUNSH_BLOB,其余 17 个与 main 逐字相同。**

## 二、`jobs` 那段注释:取 main

main 上是我合 #823 时更正过的版本(原文「恒为 0」不准确,实测采样是
`0 1 1 1 0 1 0 1` —— 不是恒 0,是从来到不了上限值)。

## 顺带记一笔

本 PR 往 L1_TESTS 里加的那行注释含一个 `)`,把 `check-l1-paths-sync` 的数组
正则 `[^)]*` 截断了(exit 2)。那是**那道门的取集缺陷**,不是本 PR 的问题,
已由 #933 单独修掉 —— 并且在那里发现它的孪生 checker 吃同一个洞却**静默判绿**,
把 11 个套件算成 7 个。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
合完 #933(修好数组解析)之后,`check-l1-paths-sync` 立刻报出本 PR 的一个真缺口:

    ::error file=scripts/qa.sh::L1 suite 'test823-l1-concurrency-cap' is run by
    qa.sh but no `paths:` entry in .github/workflows/qa.yml matches
    tests/test823-l1-concurrency-cap/. Editing that suite will not trigger the
    workflow that runs it, and nothing else would report that.

也就是:**套件注册进了 L1_TESTS,但改这个套件不会触发跑它的那条 workflow。**
对一个「测这道闸门自己」的套件来说,这一格尤其要命 —— 改坏了它自己不会响。

`pull_request.paths` 与 `push.paths` 各补一条。

复核:
    check-l1-paths-sync.py       18 个 L1 套件 / 21 条 path,全部有触发   rc=0
    check-qa-trigger-coverage.py CI-executed 12 个,全部能重触发 qa.yml    rc=0

🔴 时间顺序值得记一笔:这个缺口在 #933 之前**是看不见的** —— 那时解析器被
注释里的 `)` 截断,`l1_suites()` 返回空,门 exit 2 报的是「parse regression」。
修好取集之后,它报的才是真正的问题。**门坏掉的时候,它连自己在漏什么都说不出来。**

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vansin

vansin commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

合了。18 个 check 全绿,而且 CI 日志里能看到这个套件真跑了:

· L1 并发上限 = 4(0 = 不限;用 QA_L1_MAX_PAR 覆盖)
· build test823-l1-concurrency-cap
✓ L1 test823-l1-concurrency-cap (RESULT: PASS)

这个套件的两个设计选择我都认同,而且都不是想当然

① 跑真的 scripts/qa.sh,不是逻辑副本 —— 用 PATH 上的 docker 桩(qa.sh 的 dockerrun()bash -c "$*",会解析到桩),闸门代码原样执行
在副本上测只能证明副本自洽,这个仓今晚已经在别处栽过同一跤。

② 峰值用事件流算,不用采样 —— 桩每次写 START/END 纳秒戳,事后排序求最大重叠。采样会漏掉峰值,事件流不会。 这个区别在闸门刚好卡住上限时最关键。

另外那条注释也值得留:build 同步、run 后台,如果两者同样耗时,run 之间几乎不重叠,峰值恒为 1,高上限下断言就失去分辨力(第一版正是如此)。这是「测试跑绿了但它测不出东西」的典型形状。

合并时的三处

qa.sh 的 build-arg 推导取并集。 两边各解决一半,哪边单取都不行:

  • 只取 main(从 Dockerfile 读 arg 名,权威不猜):不供给 RUNSH_BLOB → test823 在自检第二步 fail-closed,本 PR 的门自己起不来;
  • 只取本 PR(按套件名推导):回退到猜名字,而 test224/test597 那种不带前缀的 ARG SOURCE_COMMIT 是名字推导表达不了的。

并集 = Dockerfile 推导 + 「声明了 ARG RUNSH_BLOB 才供给」+ 非致命 git。

逐套件核过(18 个):只有 test823 多拿一个 RUNSH_BLOB,其余 17 个与 main 逐字相同。

② 非致命 git 是硬需求,不是防御性编程。 test823 在一个只装了 bash/coreutils/procps、没有 git 的容器里重放 qa.sh。直接 $(git rev-parse HEAD) → 127 → set -e 当场中断 → docker 桩一次都没被调用 → 峰值恒为 0 → 闸门自己的回归「通过」得毫无意义

③ 🔴 qa.yml 补了 test823 的 paths: —— 这是门抓到的,不是我先想到的。

::error::L1 suite 'test823-l1-concurrency-cap' is run by qa.sh but no `paths:`
entry matches. Editing that suite will not trigger the workflow that runs it,
and nothing else would report that.

对一个「测这道闸门自己」的套件,这一格尤其要命 —— 改坏了它自己不会响。

一个时间顺序上的注脚

这个缺口在 #933 之前是看不见的:那时数组解析器被本 PR 注释里的一个 ) 截断,l1_suites() 返回空,门报的是「parse regression」而不是真正的问题。

门坏掉的时候,它连自己在漏什么都说不出来。

@vansin
vansin merged commit f5577aa into ci/l1-concurrency-cap Aug 18, 2026
18 checks passed
@vansin

vansin commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

🔴 更正:我在这里写的「合了」是真的,但它没有到 main

我合这个 PR 的时候只看了 draft / CI / mergeable_state,没有看 .base.ref

$ gh api repos/sleep2agi/agent-network/pulls/835 --jq '.base.ref'
ci/l1-concurrency-cap

base 不是 main,是另一条特性分支。 合并 API 照样返回 merged=true —— 它说的是「合进了它的 base」,而我读成了「进了 main」。

实测确认产物不在 main 上:

$ git ls-tree -d origin/main -- tests/ | grep 823          → 无
$ git show origin/main:scripts/qa.sh | grep -c test823    → 0
$ git show origin/main:scripts/qa.sh | grep -c RUNSH_BLOB → 0

没有丢东西 —— 全部内容都在那条远端分支上,我已开新 PR 提到 main:#934

上面那条 review 的技术结论逐条仍然成立(实测数字、变异见红、冲突取舍),只有「已经进 main」这一句是错的

🔴 这和我今晚早些时候在 #836 上撞到的 draft 是同一类:我的合并判据缺了一格,而没出事只是因为别人替我兜住了(那次是 GitHub 的 405)。这次没有兜底,所以就真的漏了。判据已补成四格:base.ref == "main"draft == false 且 无 pending 且 无 failure。

vansin added a commit that referenced this pull request Aug 18, 2026
…#934)

* ci(qa): 给 L1 加并发上限,默认 nproc,可用 QA_L1_MAX_PAR 覆盖

L1 原来是无节制后台化:L1_TESTS 有多少条就同时拉起多少个容器(当前 17 条)。
在专用 CI runner 上没问题;在开发/生产共用的机器上不行 ——
实测本机(8 核,同时跑着生产 hub、dashboard 与约 200 个 agent session)
一次 `qa.sh --l1` 把 load1 顶到 58,即 7.3x 超订;跑完回落到 30 一线。

默认上限取 `nproc`(不是更激进的 nproc/2),要同时满足两件事:
小核 CI runner 上尽量不改变现有耗时,大核共享机上把超订压下来。
`QA_L1_MAX_PAR=0` 表示不限,可完全恢复旧行为。

## 第一版是坏的,靠实测才发现

最初写的闸门是:

    while … (( $(jobs -rp | wc -l) >= QA_L1_MAX_PAR )); do sleep 0.2; done

**它从不阻塞。** `$( )` 会开子 shell,而 `jobs` 的作业表不跨子 shell 继承,
所以那个计数恒为 0。实测坐实:上限设 2,`docker ps` 采样到的 anet-* 容器峰值仍是 **3**。

改成在父 shell 里用 `kill -0` 数活着的 pid 之后:

    上限 2 → 采样峰值 **2**(分布:0×6、1×3、2×29),`ALL PASS in 40s`

两版用的是同一组 4 个套件、同一台机器、同样的采样方式(每秒数一次
`docker ps --format '{{.Image}}' | grep -c '^anet-'`),只差闸门实现。

## NOT COVERED

- **没有测 CI 上的墙钟影响**。GitHub runner 核数少,默认上限会等于那个核数,
  与现在的 17 路并发不同。当前 L0+L1 job 实测用 141–148s / 预算 300s,
  我无法在本地可靠复现 runner 的时序 —— 若复核认为有风险,
  可以在 workflow 里显式设 `QA_L1_MAX_PAR=0` 保持旧行为,或设一个更大的值。
- 只限制了 `docker run` 的并发;**build 仍是串行**,未改。

* ci(qa): 并发上限的值必须先校验 —— 否则一个笔误就静默恢复无上限

闸门条件是 `[[ "$QA_L1_MAX_PAR" -gt 0 ]]`,而 bash 在算术上下文里把非数字
当 0 —— 0 的语义恰好是「不限」。于是 QA_L1_MAX_PAR=two / =4x 会**静默恢复
本 PR 要消除的无上限行为**,同时那行 note 还照打「L1 并发上限 = two」,
输出主动确认了一个并不存在的上限。

这条不是理论问题:无上限时实测本机 load1 顶到 58(8 核,同时跑着生产 hub、
dashboard 与约 200 个 session)。fail-open 的方向正好是压垮共享机那一边。

改成 fail-closed:值不是非负整数就退回默认并大声报出来。

另修一个我自己第一版没料到的洞 —— 全数字还不够:bash 把前导零当八进制,
`[[ "08" -gt 0 ]]` 报 `value too great for base` 并返回非零,闸门照样失效;
`010` 则会被静默当成 8。加 `$((10#…))` 规范化。这个洞是跑对照表时发现的
(用例里放了 08),不是想出来的。

对照(旧 → 新):
  '4'    开        → 4  开
  '0'    不限      → 0  不限        (有意保留)
  'two'  不限      → 8  开 + 告警
  '4x'   不限      → 8  开 + 告警
  ' 3'   开        → 8  开 + 告警
  '08'   不限+报错 → 8  开
  '04'   开        → 4  开

* Merge origin/main into #823 + 更正一句注释的机制描述

结论和修法都对,合。但注释里的机制说法我复核后不准确,顺手改准。

原文:「命令替换会开子 shell,而 `jobs` 的作业表不跨子 shell 继承,
        那样数出来**恒为 0**、闸门形同虚设。」

实测(bash 5.2.21,非交互脚本,MAX=2,启 8 个后台任务):

    jobs 口径采样序列: 0 1 1 1 0 1 0 1
    kill-0 口径峰值   : 2

**不是恒 0,是从来到不了上限值** —— `(( n < MAX ))` 因此永远为真,闸门永远放行。
坏的结果一样(PR 里实测「上限设 2、docker ps 峰值 3」是对的),但机制不同。

为什么值得改这一句:下一个人照「恒为 0」去排查,一跑发现是 1,
很可能得出「这条注释过时了/不适用了」,从而把 `jobs` 版本改回来。
**一个正确的结论配一个编错的机制,会把下一步的动作导向错的地方。**

同时把另外两条陷阱各自实测确认(它们的描述是准确的):

    [[ two -gt 0 ]] → 假  ⇒ while 不进入 ⇒ 无上限(非数字在算术上下文当 0)
    [[ 08  -gt 0 ]] → bash: [[: 08: value too great for base ⇒ rc=1 ⇒ 同样静默失效
    $((10#08)) = 8

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#823): 给 L1 并发上限闸门补 Docker 回归(含变异见证) (#835)

* test(#823): L1 并发上限闸门的 Docker 回归套件

审查指出这道闸门没有可复现的回归:仓里搜 QA_L1_MAX_PAR 只有 qa.sh 一处,
提交信息里的人工采样无法从仓库复现,于是下一次 fail-open 的计数/解析回归
会静默恢复无上限运行。

套件跑的是**真的 scripts/qa.sh**,不是逻辑副本:把 docker 换成 PATH 上的桩
(qa.sh 的 dockerrun() 是 bash -c "$*",会解析到桩),真实闸门代码原样执行。
峰值用事件流算最大重叠,不用采样 —— 采样会漏峰值。

四个用例(审查点名的四种):
  cap=2      生效值 2,峰值 2         上限确实生效
  非法值 two  告警,生效值退回 nproc=8  不是静默不限
  前导零 08   生效值 8                按十进制,不撞八进制
  0          生效值 0,峰值 7         保留「不限」逃生口

对照:cap=2 峰值 2,而不限/8 时峰值 7 —— 断言有分辨力,不是恒真。

写这个套件时它自己抓到我两个 harness bug:
1) 生效值提取用 grep -oE '[0-9]+',先命中了 "L1" 里的 1,四个用例全报 1
   —— 判据没在已知输入上校准过。改成只取 `= ` 之后那个数,并用两组已知
   输入(8 / 0)校准;
2) 桩对 build 和 run 一视同仁各睡 0.35s,而 build 是同步的,导致 run 之间
   几乎不重叠、峰值恒为 1 —— 高上限下断言没有分辨力。改成 build 立即返回、
   run 睡 1.2s。

另:容器是 --network none,qa.sh 会跑 npm view 做 registry 快照,真 npm 会
等 DNS 超时而不是快速失败(第一版就这么跑成超时)。npm 一并桩掉,让被测
闸门成为唯一耗时来源。

* docs(tests): report-test823 — 在 08f54e8b 上的运行结果(report-only child)

Source 08f54e8b 是包含被测套件本身的那个提交,不是它的父提交 ——
#801 上有一条 P1 正是「report 里的 SHA 早于套件本身,证据无法从其
标注的版本复现」。这里刻意先提交套件、再按该 SHA 建镜像跑,最后
把结果作为 report-only 子提交落下。

Exit 0 / RESULT: PASS,并附去掉校验段的变异见证(RESULT: FAIL)。

* test(#823): 把 test823 注册进 L1_TESTS —— 上一版建了个没人会跑的门

自查发现:上一版新增了 tests/test823-l1-concurrency-cap/,但全仓没有任何
地方引用它。用阳性对照判实的 —— test823 在自身目录之外命中 0 处,而同类
的 test745 命中 1 处;同时确认被查文件可读、grep 能命中已知串,排除"查询
没生效"。

也就是说我在一条"补上缺失回归"的 PR 里,交付了一个不会被任何东西调用的
套件 —— 正是 #801 那条 P1(gate_is_wired)说的情形,只不过这次在我自己
的产出上。

注册后再验:test823 命中 1 处,与对照 test745 一致。

自包含也验了:注册之后套件会在自己的 L1 列表里看到自己。实测仍 PASS
(退出码 0,四个用例全绿,峰值对照依旧有分辨力:cap=2 → 峰值 2,
不限 → 峰值 7),没有递归或自锁。

* test(#823): 三条审查意见 —— 桩只记 run、断言不限真放开、SHA 绑到被测字节

① 桩原来对任何非 build 的 docker 子命令都记事件,峰值会被无关调用抬高。
   改成只有 `docker run` 才记。
② 0(不限)原来只断言生效值为 0 —— 那只证明它被这么解析,没证明它真的
   放开了并发。补一条:不限时峰值必须明显高于 cap=2 的峰值。
③ SOURCE_COMMIT 原来只验 40 位十六进制格式。任何 SHA 都能通过,而报告
   里那个 SHA 可能根本不含镜像里被测的文件 —— 这正是我自己在 #801 上
   提的那条 P1,建这个套件时原样犯了一遍。
   改成:构建时把 run.sh 在该 commit 下的 git blob 哈希作为 build-arg
   传入,容器内就地重算并比对(blob 哈希 = sha1("blob <len>\\0"+内容),
   不需要容器里装 git)。

第四条「接进自动 workflow」上一提交已自查修掉(注册进 L1_TESTS),
审查针对的是修之前的坐标。

* docs(tests): report-test823 刷新到 76c12e98379b67eb074a2a42e2a170e6aa94db1f(含 blob 绑定与三种见证红)

* ci(qa.sh): SOURCE_COMMIT 改为按套件名推导 —— 逐套件 elif 正是本 PR 撞红的成因

本 PR 的 L0+L1 稳定红,失败行只有一句:

  FAIL: TEST823_SOURCE_COMMIT 必须是一个完整的小写 SHA(收到 '')

根因不在被测的门,在供给侧。qa.sh 里原本是一串逐套件的 elif:

  if   [[ "$t" == "test686-rest-shape-golden" ]]; then --build-arg TEST686_SOURCE_COMMIT=…
  elif [[ "$t" == "test765-batch-runtime-gate" ]]; then …
  elif [[ "$t" == "test766-bunx-preflight" ]];     then …
  elif [[ "$t" == "test746-setup-bun-pin" ]];      then …
  fi

本 PR 把 test823 加进了 L1_TESTS,但没人记得这里也要加一条 —— 于是
TEST823_SOURCE_COMMIT 是空串,门正确地 fail-closed。

只补一条 elif 能让它变绿,但下一个新套件还会踩同一个坑:
「注册了套件」和「在供给侧登记」是两处,分开就会漂。所以改成按名推导:

  testNNN-...  →  --build-arg TESTNNN_SOURCE_COMMIT=$(git rev-parse HEAD)
  qa-*-...     →  不传(与原行为一致,它们的门不要这个变量)

行为等价性验证(对当前 L1_TESTS 全部 18 个套件逐个模拟):

  test823-l1-concurrency-cap   → TEST823_SOURCE_COMMIT   (新增,本 PR 需要的)
  test686-rest-shape-golden    → TEST686_SOURCE_COMMIT   (与原 elif 一致)
  test765-batch-runtime-gate   → TEST765_SOURCE_COMMIT   (一致)
  test766-bunx-preflight       → TEST766_SOURCE_COMMIT   (一致)
  test746-setup-bun-pin        → TEST746_SOURCE_COMMIT   (一致)
  qa-cli-01 / qa-hub-05 / qa-node-03b / …  → 不传        (一致)

bash -n 退出码 0。

顺带记一条同类:#801 的红是同一个形状 —— run.sh 要求 TEST798_RUNSH_BLOB、
Dockerfile 接了线、workflow 的 docker build 从没传。都是「门要求 X,
供给侧不知道要给 X」。

* ci(qa.sh): 两套 build-arg 命名都供给 —— 上一版只覆盖了旧的那套,test823 照旧红

上一个提交(2bb734af)把逐套件 elif 改成按名推导 TESTNNN_SOURCE_COMMIT。
方向对,但**覆盖不全**:CI 照旧红在同一行

  FAIL: TEST823_SOURCE_COMMIT 必须是一个完整的小写 SHA(收到 '')

原因是仓里并存两套命名,而我只按其中一套推导:

  tests/test686-rest-shape-golden/Dockerfile   ARG TEST686_SOURCE_COMMIT
  tests/test765-batch-runtime-gate/Dockerfile  ARG TEST765_SOURCE_COMMIT
  tests/test766-bunx-preflight/Dockerfile      ARG TEST766_SOURCE_COMMIT
  tests/test746-setup-bun-pin/Dockerfile       ARG TEST746_SOURCE_COMMIT
  tests/test823-l1-concurrency-cap/Dockerfile  ARG SOURCE_COMMIT / ARG RUNSH_BLOB   ← 不一样

test823 的 Dockerfile 收的是 `SOURCE_COMMIT`,再由它自己组装
`ENV TEST823_SOURCE_COMMIT=${SOURCE_COMMIT}`。我传的是 TEST823_SOURCE_COMMIT,
名字对不上 → ARG 空 → ENV 空 → 门 fail-closed。它还要 RUNSH_BLOB(run.sh:28)。

这次两套都传。未被 Dockerfile 声明的 build-arg 只产生一条警告,不影响构建。

blob 等价性实测(本分支 head 上):

  git rev-parse HEAD:tests/test823-l1-concurrency-cap/run.sh
  { printf 'blob %d\0' "$(wc -c < run.sh)"; cat run.sh; } | sha1sum
  两者相同 —— 与 run.sh:31 的算法一致。

bash -n 退出码 0;对 L1_TESTS 里各形态逐个模拟,qa-* 仍不传。

🔴 记一条:上一版我验证了「四个旧套件行为逐条复现」,那个验证是对的,
但它只覆盖了我知道的那套约定 —— **我没有去核每个 Dockerfile 实际声明了什么 ARG**。
「与原行为一致」不等于「对所有套件都正确」。

* ci(qa.sh): git 调用改为非致命 —— 上一版把闸门自己的回归打红了(我引入的)

第三次 CI 仍红,但**换了一种红法**,而且这次是我造成的。

前两次红的是 `TEST823_SOURCE_COMMIT 收到 ''`。那个已经修好了 ——
本次日志里 `source_commit=1f2ab57a…` 正常出现、blob 校验也过了。

这次红在:

  - cap=2      峰值=0 生效值=2 告警=0     FAIL cap2
  - 0(不限)   峰值=0 生效值=0 告警=0     FAIL zero_conc
  failures=2

**每个用例的峰值都是 0** —— 桩一次都没被调用。根因:

  scripts/qa.sh:17          set -euo pipefail
  test823 的 Dockerfile     只装 bash / ca-certificates / coreutils / procps —— **没有 git**
  test823 的 run.sh         桩了 docker 和 npm,**没有桩 git**

而我上一版把 `$(git rev-parse HEAD)` 从「4 个具名套件」扩到了「所有 testNNN 套件」。
于是在 test823 重放 qa.sh 的那个容器里:git 不存在 → 127 → set -e 当场中断 →
docker 桩一次没被调用 → 事件流为空 → 峰值恒 0 → 闸门自己的回归失败。

**这不是被测代码的问题,是我改出来的回归。**

修法:git 调用全部 `2>/dev/null || true`,取不到就不拼 build_args ——
无 git 环境下退回到「和我动手之前一样」的行为(不传 build-arg),
真 CI 里 git 在,照常传。

模拟验证(PATH 置空以制造无 git 环境,带 set -euo pipefail):
未中断,build_args 为空。bash -n 退出码 0。

🔴 教训:我改的是**一个会被别的门重放的脚本**。给它加依赖(git)时,
我只想着「CI runner 上当然有 git」,没想过它还会在一个刻意最小化的容器里被重放。
「这个环境肯定有 X」——当脚本本身是被测对象时,这句话要先证明。

* fix(cli): 起/停这几条路不再宣布没量过的成功 (#895)

* fix(cli): stop `anet node start --accept-dev-channels` reporting dead nodes as started

Two independent false greens on this path, both measured while restoring 97
nodes after a power loss on 2026-08-17.

1. The success line was printed on the strength of `tmux new-session -d`
   returning. That call succeeds even when the inner `anet node start` refuses
   and exits 1 a moment later, so a refused node printed
   `✅ node "X" started detached (tmux session live; …)` and exited 0 — with
   `can't find pane: X` on the line directly above it. Byte-identical to a real
   success, so a batch restore counted 64/64 up when 6 had never started.

   Now: unstartable profiles are refused before anything is spawned (same
   resolveStartProfile check launchAgent runs, so the message is the real one),
   and success is claimed only after verifyNodeUp — the function `project up`
   already uses to decide whether a node came alive. The success line quotes
   its evidence (`pid N alive`) instead of asserting a session it never checked.

2. The 45 s auto-confirm window was spent on the wrong prompt. A workspace
   Claude Code has not seen before shows folder-trust FIRST; the watcher knew
   only the dev-channels markers, so it stared at a prompt it would not answer
   until the window closed, and the dev-channels prompt that appeared later was
   never confirmed. The node hung silently and the hub showed it offline
   (TM智空负责人 died exactly this way and needed two manual Enters).

   Now the watcher answers folder-trust too and restarts its clock, because the
   window is meant to bound the wait for ONE prompt, not for the whole
   trust-then-channels sequence.

A failed start deliberately does not kill the tmux session — a node stuck on a
prompt is one keypress from working — but the failure output names the session
and says `tmux has-session` will answer yes for it, since that is the criterion
batch callers use.

Verified against the two failure modes and the happy path in an isolated
workspace, with the inner agent stubbed:

  bogus runtime   before: ✅ exit 0   after: refusal on stderr, exit 1, no spawn
  dies on start   before: ✅ exit 0   after: ❌ exit 1 quoting the pane's reason
  trust sequence  before: ✅ exit 0, 46 s, node hung with no pid
                  after:  ✅ exit 0,  5 s, pid alive, both prompts confirmed

All 6 wiring assertions fail against the unmodified file; both pure-module
mutations turn their tests red. Package suite 454 pass, tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cli): apply the same verify-before-claiming rule to --tmux and codex co-presence

Auditing the other 54 `✅` claims in the CLI for the same class as the
--accept-dev-channels false green. Most are honest — `hub start`, the dashboard
launcher and the co-presence app-server all measure before they claim (a
/health fetch, a listener-pid scan, waiting for the `listening on:` line). Two
were not.

`anet node start <alias> --tmux` polls `tmux has-session` for 2 s and calls that
proof. It isn't: tmux registers the session before the inner command has
finished failing, so an unsupported runtime printed
`✅ tmux session "X" started detached` and exited 0 — the session was gone two
seconds later. Measured, then fixed with the same refuse-before-spawning check.
The narrower claim this path makes (the SESSION started, not the node) is left
as-is; it is true, and unlike --accept-dev-channels this path cannot promise a
working node because it never answers the prompts.

The codex co-presence launcher spawns three tmux sessions and then declares the
node 就绪. Only ① proved itself. Its OpenCode twin already checked its TUI
session before making the same claim, so the two sibling paths disagreed about
whether "ready" is measured; now they agree, and 就绪 requires all three
sessions to be alive at the moment it is printed.

Not verified end-to-end: the codex co-presence path needs a working codex, and
this account's quota is exhausted until 2026-08-20. The change mirrors the
OpenCode twin's shape exactly and only adds a failure path where a session is
genuinely absent.

The first version of the --tmux gate passed against the unfixed file — anchored
loosely enough that it found the OTHER branch's preflight. Rescoped to the
--tmux path itself. 3 of the 4 assertions now fail against both origin/main and
the previous commit; the fourth is the OpenCode twin, green on all three
because it is the reference, not a change. Suite 458 pass, tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cli): say which condition made a resolved agent-node unsafe, and name umask

Chasing why the 5 grok co-presence nodes were unstartable. The published anet
(2.3.0-preview.38) has no grok-build-cli in its runtime whitelist, so the
morning's conclusion was "rewrite their config to grok-build-acp". That was
wrong twice over: origin/main already whitelists grok-build-cli, and once past
that the real blocker turned out to have nothing to do with grok.

Measured chain on this machine:

  umask                                            0002
  npx -y @sleep2agi/agent-node@preview   dist/cli.js 0775, package.json 0664
  the check                              (mode & 0o022) !== 0  → refuse
  0o775 & 0o022                          = 0o020  (group-write alone)
  what the operator saw   [anet] Incompatible grok-build-cli runtime.
                          [anet] resolved agent-node package has unsafe ownership or mode

Owner was correct throughout (uid 1000, my own), so the sentence sent every
reader to look at ownership. `chmod g-w,o-w` on those two files made the same
command run all the way through to the agent-node process, failing only on the
fake hub the test config points at — which is what confirmed the diagnosis.

The check is right and stays: refusing to execute a payload the group can
rewrite is correct, and anet cannot know this box's group has one member. What
changes is that it now names the path, the octal mode, which of the four
conditions fired, and that the usual cause is a stock Debian/Ubuntu umask —
with both fixes spelled out. Ownership failures deliberately do NOT mention
umask, so that message stays about ownership.

Both call sites share the new pure module: the grok preview resolver in cli.ts
and the OpenCode pairing check, which enforces the identical rule and would
have produced the identical dead end. The existing assertion on the old wording
still matches — the sentence is kept as the headline and the diagnosis appended.

Suite 465 pass, tsc clean. The npx directory was left exactly as found
(775/664); the fix is the operator's to apply.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(doctor): warn about the umask that makes grok-build-cli and opencode-cli unstartable

A better error message only helps someone already stuck. `anet doctor` can see
this coming from local state alone.

Both runtimes refuse a resolved agent-node payload whose mode has a group- or
other-write bit. npm creates files as `0o666 & ~umask`, so a stock
Debian/Ubuntu umask of 0002 — every user gets a private group, so 0002 is the
distro default — guarantees 0775/0664 and guarantees the refusal, which reaches
the operator as "Incompatible grok-build-cli runtime" with no mention of umask.

doctor now reports two things, from the process umask and whatever is already
extracted under ~/.npm/_npx. It never fetches, so an empty payload scan means
"nothing extracted yet", not "safe" — the umask verdict is what speaks to the
next fetch. On this machine:

  ⚠  Package file modes: umask is 0002, so npm extracts packages
     group-writable. grok-build-cli and opencode-cli refuse to execute a
     payload in that state, and the refusal reads as an "Incompatible runtime"
     error. Start those runtimes under `umask 0022`, or run
     `chmod -R g-w,o-w` on the resolved package root.
  ⚠  Resolved agent-node payload: 2 already-extracted file(s) would be
     rejected right now, e.g. …/@sleep2agi/agent-node/dist/cli.js (mode 775).
     Fix: chmod -R g-w,o-w …/@sleep2agi/agent-node

A set umask bit means "withhold that permission", so the predicate reads
inverted from how the symptom presents; that inversion is why judgeUmask is a
tested function rather than an inline expression, and 0002/0022/0000/0077 are
each pinned. Reading the umask requires the POSIX set-and-return call — the
helper puts the old value straight back, verified equal on a second read.

Suite 474 pass, tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cli): address tmux sessions exactly — bare -t prefix-matches a sibling node

Every human-facing string in this CLI already spells the exact form
(`tmux attach -t '=<alias>'`, with a comment at the OpenCode co-presence
launcher explaining that a missing TUI would otherwise silently attach to the
bridge). Every tmux command the CLI actually ran passed the bare name.

Measured on this machine with only `zz-honest-probe-extra` alive:

  tmux has-session -t zz-honest-probe    → success  (it is not running)
  tmux has-session -t =zz-honest-probe   → failure  (correct)
  tmux kill-session -t zz-honest-probe   → killed zz-honest-probe-extra

The live fleet here has four colliding pairs — A站内容/A站内容牛,
A站数据/A站数据牛, P站测试/P站测试牛, P站运维/P站运维牛 — so each of the
three consequences is reachable today:

  * has-session false-positives → `node start --accept-dev-channels` prints
    `tmux session "X" already running — skipping spawn` for a node that is
    down, exits 0, and never starts it. Reproduced end-to-end: with only
    `anet-collide-兄弟` alive, origin/main skipped the spawn and left no pid;
    the fixed build started the node (pid alive) and left the sibling running.
  * kill-session reaps the sibling, and `node stop` reports success.
  * send-keys would deliver an Enter into the sibling's Claude UI — the worst
    of the three, since the prompt watcher fires it unattended.

All eight call sites now go through one helper: kill-session, has-session,
capture-pane ×4, send-keys ×2.

killTmuxSession additionally returns whether the session is actually gone. Its
`kill-session` failure is swallowed on purpose — a session that already exited
is the common case — so the only way to know is to look afterwards. `node stop`
now checks that and refuses to report a stop it did not achieve, instead of
deriving "killed" from the pre-kill has-session probe and notifying the hub
offline over a session that is still up.

Suite 478 pass, tsc clean. Fleet untouched at 89 sessions throughout; the
integration test uses its own `anet-exacttest*` names and cleans up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: t <t@t>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(cli): project up / restart 的退出码要反映节点是否真的起来了 (#896)

Follow-up to #895, one level up. That PR fixed the single-node start paths; this
is the same defect class in the batch entry point, and it is the one automation
actually calls.

`anet project up` already measures every node with verifySpawnedNodes and prints
each failure, so its OUTPUT was honest — unlike the single-node path, it never
claimed a dead node was started. What it did not do was set an exit code: both
projectUp and projectRestart returned normally, so a run that brought up 60 of
74 nodes exited 0.

That matters because this is the entry point scripts use. While reviewing a
boot-time sweep for this machine's ~74 agent nodes, the design leaned on a
post-flight tmux audit rather than on `$?` — and the reason turned out to be
load-bearing rather than stylistic: `grep -c process.exit` inside projectUp
returns 0. Any watchdog or CI step that trusted the exit code was being told the
fleet was fine.

`invalid` counts toward failure too. A node whose config cannot start was never
attempted, so exiting 0 hides it exactly as well as a crash does.

The gate runs after printProjectSummary so the operator still gets the full list
before the process dies, and a clean run returns early and stays at exit 0.

All 5 assertions fail against f565e9b8 and pass here. Suite 483 pass, tsc clean.

Co-authored-by: t <t@t>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* docs: 去掉过期版本号与硬编码计数,改为指向权威来源 (#869)

Doc-only refresh from 通信狗 review (issue #639). No behavior changes,
no runtime/config edits — every touched file is markdown or a
package.json `description` field.

## Version facts sourced from npm at commit time

Verified via `npm view <pkg> dist-tags` on 2026-08-14:
  latest  : agent-network 2.2.21 / agent-node 2.4.13 / commhub-server 0.8.8
  preview : agent-network 2.3.0-preview.39 / agent-node 2.5.0-preview.31
            commhub-server 0.9.0-preview.29
  local anet: v2.3.0-preview.38 (matches preview channel, one behind head)

## Per-file changes

P0-1 CHANGELOG.md banner
  - Drop hardcoded "当前 stable 是 v0.10.11" (out of date).
  - Point readers at npm `latest` + docs-site/docs/changelog.md as the
    live source; keep the v0.10.15 archival anchor + v0.8.1 OSS-first
    note. File still an archive of pre-2026-04 v1.0.0-preview history.

P0-2 docs/getting-started.md
  - Runtime table now includes `grok-build-acp` (needs `grok login`).
  - Note that @preview additionally ships `codex-app-server` and
    `opencode-cli`; the authoritative full runtime table is at
    anet.sh/guide/runtimes.
  - `anet ls` → `anet node ls` (matches current CLI).

P0-3 docs-site/docs/{,en/}guide/getting-started.md
  - Add a preview-channel warning next to the admin/anethub line:
    @preview prints a one-time random password on first
    `anet hub start`, don't hard-code `anethub`. This aligns with the
    README + cli.md wording that already carries the note.

P0-4 AGENTS.md 项目结构
  - Drop "39 命令" / "4 runtime" hardcoded counts (both drift).
  - Point at docs-site/docs/guide/cli.md as canonical CLI list.
  - Split runtimes into stable (4: claude-code-cli / claude-agent-sdk /
    codex-sdk / grok-build-acp) + preview extra (2: codex-app-server /
    opencode-cli); mark `grok-build-cli` as unreleased in any channel.

P0-5 docs/version/README.md + docs/plans/release-plan.md
        + docs/version/0.11.0/README.md
  - Backfill preview matrix from `npm view <pkg>@preview` (was pinned
    at .34/.26/.20 — now .39/.31/.29). Added timestamp + reminder to
    re-check `npm view` before editing.
  - WAIC 7-月-下旬 anchor is out of the window; strike-through the
    completed date, keep the archival link to waic-release.md, and
    replace with "current promote status per release-plan".
  - release-plan.md defaults table gains a commhub-server column so
    readers see all three packages, not just two.

P1-6 docs-site/docs/guide/architecture.md
  - "14 张表" → "20+ 张表(含 sessions / tasks / nodes / users /
    networks / SkillHub / providers / vault 等,实数按 schema
    版本浮动)"; EN mirrors it. Fixes both the two mermaid diagrams
    and the paragraph.

P1-7 docs/architecture.md
  - Runtime paragraph now says "stable 4 + preview 2" and points at
    anet.sh/guide/runtimes as authoritative.
  - 14 cli.ts deep `#L<line>` anchors defanged (link stays, line
    number dropped — they rot every release; kept the function name
    in the link text so intent survives).
  - Directory tree gains an "已不完整,以仓库实际为准" note so
    readers don't treat it as canonical.

P1-8 server/package.json + server/README.md
  - package.json `description` now says "MCP tools (17 collaboration-
    core + node/provider ops tools; authoritative list at
    docs-site/docs/api/mcp-tools.md)" — was "and 17 MCP tools"
    (readers took it as the total).
  - server/README.md MCP section gains one line saying the 17 in the
    table are the collaboration-core subset; full list at
    docs-site/docs/api/mcp-tools.md.

P1-9 README.md + README.en.md
  - "能做什么" / "What it does" gain one bullet pointing at Codex TUI
    co-presence and OpenCode as preview-channel additions with a link
    to the Runtime page.

## Not touched (per review scope)

  - docs/v3-postgresql-design.md archive banner (do not edit)
  - upgrade-v2 archive banner (do not edit)
  - grok-copresence danger banner (do not edit)
  - runtimes 官方表 (canonical, do not edit)

## Verification

`grep -c` on the touched files confirms:
  - `14 张表` remaining in docs-site/docs/guide/architecture.md: 0
  - `39 命令` remaining in AGENTS.md: 0
  - stale `cli.ts#L<n>` deep anchors in docs/architecture.md: 0
  - `anet ls` (bare, without node prefix) in docs/getting-started.md: 0

Co-authored-by: t <t@t>

* fix(ci): 让 CI 真会跑的测试能重触发自己那道门,并加一道防漂回去的门 (#897)

Four findings from the open-issue triage, each verified against origin/main
before touching anything. Two of the four numbers in those issues were wrong in
ways worth recording.

## qa.yml path filter missed tests CI actually runs (#860)

qa.yml fires on a path filter. Four directories CI executes were outside it, so
editing the test could not re-run its own gate — and the run looks identical to
a gate that passed on the new code:

  tests/test292-e2e-hard-gate        (referenced by a workflow path)
  tests/test686-rest-shape-golden    ┐
  tests/test765-batch-runtime-gate   ├ reached through scripts/qa.sh L1_TESTS
  tests/test766-bunx-preflight       ┘

#860 reported three; it missed test292-e2e-hard-gate. My own first scan
under-counted in the other direction — it matched `tests/testNNN` and so never
saw L1_TESTS, which names directories bare. The remaining ~160 directories under
tests/ are run by no workflow at all, and are deliberately left out: a filter
entry for an unrun test reads like coverage it does not have.

## …and a guard so it does not drift back

`.github/scripts/check-qa-trigger-coverage.py` asserts every CI-executed test
directory is in the filter. Three behaviours, each exercised:

  fixed repo            → exit 0, "all 7 CI-executed test dirs can re-trigger"
  f565e9b8's qa.yml     → exit 1, names all four with the line to add
  L1_TESTS renamed away → exit 2, "no CI-executed test directories detected"

That last one matters most: if the parser stops matching, the honest answer is
"I can no longer see the denominator", not a clean run against nothing.

Its workflow intentionally carries NO `paths:` filter. It guards a path filter;
gating it on paths would let an edit to qa.yml's filter or to L1_TESTS slip past
the check that watches them — the same blind spot it exists to catch.

## public-script safety: TLS verification (#890)

check-public-script-safety.py flagged `rm -rf` outside our paths and unscoped
`pkill`, but nothing about `curl -k` / `--insecure` /
`wget --no-check-certificate` / `NODE_TLS_REJECT_UNAUTHORIZED=0`. These scripts
are fetched over https and piped into bash, so verification is the reader's only
defence against a tampered download; there is no legitimate reason for a script
published at a public https URL to skip verifying it. That meets the file's own
"only unambiguous rules" bar. Zero current hits — this one is preventive.

While adding it I hit a bug in the reporting: the hint was chosen by an
if/else whose `else` branch belonged to the kill rule, so every TLS finding
printed advice about `pkill -u`. Remediation text is now keyed by rule, and an
unknown rule exits 2 rather than borrowing another rule's advice — pointing the
reader at a problem they do not have is worse than printing nothing.

Verified with real exit codes (not through a pipe, where `$?` is the last
command's): known-bad fixture → exit 1 with the right hint on all three forms,
comment lines ignored; real repo → exit 0 across 6 scripts.

## dashboard min_uptime (#892)

deploy/dashboard/ecosystem.config.cjs had `min_uptime: 20_000` while
docs-site/docs/deploy/daemon.md documents 45000 and explains why: below the
time a failing process takes to exit, PM2 counts the start as successful,
backoff never engages, and a crash loop looks like normal restarts. A dashboard
rebuilt from this repo landed exactly in that gap. Aligned to 45000 with the
reasoning inline; `node -e require(...)` confirms it still parses.

Co-authored-by: t <t@t>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(docs,ci): 修 W19 编码与死链、给矛盾耗时标条件、把两个没人调的验证脚本挂上 (#899)

Four more from the open-issue triage. Each was verified against origin/main
first, and two of them turned out to be bigger than the issue said.

## docs/qa/weekly/2026-W19.md would not decode as UTF-8 (#887)

Three multi-byte characters were truncated mid-sequence, not one. The issue
reported the first; repairing it revealed the second, and that one the third.
The damage pattern is consistent — every case is `_italic text_` with the
character immediately before the closing `_` eaten — which points at a
truncating edit rather than a bad encoding.

The lost characters are NOT recoverable, so they are marked as damaged rather
than guessed. This is a QA weekly report; inventing a plausible character would
be worse than saying a character is missing.

## …and all 24 of its relative links were dead (#872)

Not "24 broken links" in the sense of a few typos: 0 of 24 resolved. The file
sits three levels deep and the links were written for two, so every `../../`
landed inside docs/ instead of at the repo root. Four more used a single `../`
for directories that live under tests/. All 24 now resolve — verified by
resolving each one against the filesystem, not by eyeballing the diff.

## docs/qa timings contradicted each other three ways (#871)

  docs/qa/README.md          ~16s warm
  docs/qa/strategy.md        ~16s warm
  docs/qa/v0-summary.md      ~93s local, ~40s CI
  v0-summary's own per-test table, summed:   156s

The issue framed this as "pick one and unify". None of the three can be picked,
because not one of them says what it measured — warm or cold, serial or
parallel, which machine. 156s serial against a 93s wall clock just means there
is parallelism nobody wrote down.

So the dead numbers are gone from README and strategy, replaced with
`time bash scripts/qa.sh` and the one fact that stays true: the per-test table
sums to 156s, anything lower implies parallelism, cold starts are worse.
v0-summary keeps its 93s but now says it is a 2026-05 measurement.

## scripts/verify-published-pins.sh and verify-release-tag.sh had zero callers (#862)

Both committed, both executable, both carrying the incident that motivated them
in their header — and `grep -rl` across .github/ and scripts/ found nothing that
invokes either. A guard nothing calls protects nothing, while its presence reads
as if the risk were covered.

Running verify-published-pins.sh by hand, for the first time, failed on its
first invocation:

    ❌ OPENCODE_AGENT_NODE_VERSION 期望 2.5.0-preview.31,
       产物里是: 2.5.0-preview.28
    1 个 pin 与已发布产物不一致 —— main 修了但用户装到的包没修

That is the exact distinction its own header says bit this repo three times in
one day, live in the published preview and undetected. (Independently confirmed
by hand earlier the same night: installing preview.39 demands agent-node
preview.28, while main's source constant reads preview.31.)

Now scheduled daily plus manual dispatch, with the exit codes mapped so that
"could not measure" does not become the same green as "measured and fine":
rc=2 (registry unreachable) fails with a notice saying the run verified nothing.
Per-PR would be wrong — it inspects the published artifact, which a PR does not
change.

## A guard for the first two

`.github/scripts/check-docs-integrity.py` checks UTF-8 validity across every
tracked .md and relative-link resolution under docs/qa/. Three behaviours, each
exercised: repaired tree → exit 0 (359 files, 80 links); f565e9b8's W19 → exit 1
with 25 errors naming each; LINK_SCOPE pointed at a missing directory → exit 2,
"scope regression, refusing to pass".

It starts green, so it is not a backlog canary — a red here will always mean
something just broke. Link checking is scoped to docs/qa/ and says so: some
pages elsewhere link to generated paths, and a guard that cries wolf gets
disabled.

Co-authored-by: t <t@t>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(cli): 让 dev-channels 自动应答真的能用 —— pane 目标用坐标 + 候选按 server: channel (#901)

* fix(cli): revert `=name` on pane commands — it cannot resolve non-ASCII sessions

Regression I introduced in #895 and merged. #895 replaced eight bare `-t <name>`
tmux targets with `-t =<name>` to stop prefix matching. That is correct for
session-targeting commands and WRONG for pane-targeting ones.

Measured on tmux 3.4 with a session literally named `zz中文探针`:

    tmux has-session   -t 'zz中文探针'   rc=0     -t '=zz中文探针'   rc=0
    tmux kill-session  -t 'zz中文探针'   rc=0     -t '=zz中文探针'   rc=0
    tmux capture-pane  -t 'zz中文探针'   rc=0     -t '=zz中文探针'   rc=1  can't find pane
    tmux send-keys     -t 'zz中文探针'   rc=0     -t '=zz中文探针'   rc=1  can't find pane

This fleet's session names are nearly all Chinese, so #895 silently disabled the
dev-channels prompt watcher for essentially every node: capture-pane throws, the
watcher reads that as "session gone", returns false immediately, and the confirm
box is never answered. The node then sits on the prompt forever. That is worse
than the prefix ambiguity the `=` was added to fix, and it is the same failure
mode #895's second half existed to eliminate.

Caught on a live node. `SDK马` was sitting on the dev-channels box with a live
pid, and:

    capture-pane -t '=SDK马'    → rc!=0
    capture-pane -t 'SDK马:0.0' → rc=0, 16 lines, prompt visible

The exact-and-portable form for a pane is the coordinate
`<session>:<window>.<pane>`, resolved by listing panes and matching the session
name with string equality in our own code — which is both unambiguous and
encoding-agnostic, instead of asking tmux to disambiguate. `has-session` and
`kill-session` keep `=name`; they accept it for non-ASCII and still need the
prefix guard.

The watcher now re-resolves the coordinate on every poll rather than caching it:
a session may have no pane on the first iteration, and "no pane yet" must not be
mistaken for "prompt absent" — it keeps waiting and lets the deadline decide.

The wiring assertion fails against f565e9b8..7752437f and passes here; the pure
parser is pinned for prefix siblings, missing sessions, non-zero pane indexes,
and malformed rows. There is also an integration test that creates a real
non-ASCII session and asserts the exact rc difference above, so this cannot
regress silently again. Suite 491 pass, tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cli): auto-confirm dev channels for every node that loads one, not only claude-code-cli

Second half of the same failure. #895's `=name` change stopped the watcher from
seeing the pane; this is why the watcher was never even asked to look at a whole
family of nodes.

`autoConfirmDevChannels` selected candidates with

    normalizeRuntime(n.profile) === "claude-code-cli" && channels has "server:"

but the runtime is not what causes the prompt — loading a `server:` channel is.
`claude-agent-sdk` nodes with `server:commhub` show the same confirm box, and
`claude-code` normalizes to `claude-agent-sdk`, so legacy-named nodes were
excluded too. Those nodes sat on the box forever during `project up` /
`node start --all` with no watcher assigned to them.

The correct predicate was already in this file. The #494 warning on the `--tmux`
path keys purely on `server:` channels with no runtime test:

    if ((resolved.profile.channels ?? []).some(ch => ch.startsWith("server:")))
      console.warn(`[anet] ⚠ this node loads dev channels (server:*): …`)

Two places answering the same question with different rules, and the narrow one
was the one doing the work.

Measured on this machine, all three with `channels: ['server:commhub']`:

    微信马       claude-code-cli    → was selected, came up (late, but up)
    评估m马      claude-agent-sdk   → not selected, sat on the confirm box
    I站工程马     claude-code        → not selected (normalizes to agent-sdk)

Widening is safe because dismissDevChannelPrompt is detection-gated: Enter is
sent only when the prompt's exact text is on screen, so a node that never shows
it times out without a keystroke.

Both assertions fail against main and pass here. One of them initially failed
against the FIXED code too — the new comment quotes the old predicate verbatim,
and a plain `toContain` matched the comment. The test now strips comment lines
before asserting absence, because the claim is about the code. Suite 493 pass,
tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: t <t@t>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* docs(refresh): stale-snapshot caveats on 4 独立面 (task 27faa700) (#898)

* docs(refresh): 4 独立面 stale-snapshot caveats (task 27faa700)

Local-only branch. NOT pushed, NO PR opened — awaiting 通信龙 review after
PR #869 merges (per instruction 2026-08-14).

Doc-only, no behavior changes. Each edit adds a snapshot-date caveat and
points at the live source of truth (release-plan.md / `npm view`); no
existing evidence-anchored text was removed.

## 1. docs-site/docs/{,en/}preview/index.md:16 (章节标题死数)

- Old heading: "当前 preview = canonical(2.3.0-preview.34 / 2.5.0-preview.26,2026-07-16)"
- New heading: "当前 preview channel canonical build(snapshot 2026-08-14)"
- Added 1 paragraph with:
  - real 2026-08-14 npm-view numbers (agent-network preview.39 /
    agent-node preview.31 / commhub-server preview.29)
  - the main-source-vs-published-binary caveat (通信龙 Fact 1):
    preview.39 binary's embedded .d.ts pair still names
    agent-node@2.5.0-preview.28
  - install-via-@preview-tag reminder (already stated once above,
    reinforced with npm view <pkg> dist-tags recipe)
- English mirror updated with parallel wording.

## 2. docs/release/v2.3.0/plan.md:30 (GA-gate 6-week-old snapshot)

- Prepended one blockquote line marking the段 as a 2026-07-05 snapshot,
  pointing at release-plan.md as the live source, and citing the
  2026-08-14 real preview numbers.
- Kept the original "最后更新:2026-07-05 …" line intact for GA-gate
  milestone history.

## 3. docs/release/versioning-and-compatibility.md:37-43 (fleet snapshot table)

- Prepended one warning line: the first three rows are 2026-06
  preview-iteration snapshots; live numbers via release-plan.md +
  npm view.
- Renamed rows 39-41 to append "(2026-06 快照)" so readers cannot
  misread them as current.
- Added one new row "已发布 preview 头(snapshot 2026-08-14)" with
  real npm-view numbers + the .d.ts pair caveat (通信龙 Fact 1).
- Kept "v2.3.0 GA 目标" and "latest(稳定线)" rows unchanged (真值
  仍准).

## 4. docs/runbooks/feishu-channel-ops.md:11-18 (runbook 死数)

- Runbook section title now names as-of 2026-07-01 deployment
  snapshot + instructs to复核 real deployed version via
  `docker exec anet-feishu-local anet -v` (does not touch prod —
  runbook only tells the operator what to run).
- agent-network / agent-node rows retain the deployed values but
  add "当前 preview 头 2026-08-14 快照为 preview.39 / preview.31" +
  release-plan.md link for cross-check.

## Version facts used (npm view 2026-08-14)

| pkg | latest | preview |
|---|---|---|
| agent-network | 2.2.21 | 2.3.0-preview.39 |
| agent-node | 2.4.13 | 2.5.0-preview.31 |
| commhub-server | 0.8.8 | 0.9.0-preview.29 |

## Not landed here

- Q2 段全部 8+ 条 (行为句缺版本) — 需 git log 追溯精确 version anchor,
  单独任务处理 (通信龙 视 #869 merge 时机再拍)
- 通信龙 Fact 2 (anet node start ✅ vs tmux has-session) — origin/main
  未直接命中"看到 ✅ 就成功"教学句, README / clean-server.md / feature-
  audit.md 可通过通用页脚 note 批量处理, 单独任务处理
- docs/RELEASE-SOP.md:232/243 pairing caveat 详写 — 属 SOP 内部, 请
  RELEASE-SOP owner 决定文案
- docs/grok-build-runtime.md:111 (v0.10.11 anchor stale) — 属 Q2 段
  item 16, 单独 issue

* docs(refresh): bump snapshot date 2026-08-14 → 2026-08-17 (rebase day)

Per 通信龙 instruction on task 27faa700: snapshot dates should reflect
the rebase/push day, not the authoring day.

Re-verified `npm view <pkg> dist-tags` on 2026-08-17 — numbers unchanged
since 2026-08-14 authoring:

  latest  : agent-network 2.2.21 / agent-node 2.4.13 / commhub-server 0.8.8
  preview : agent-network 2.3.0-preview.39 / agent-node 2.5.0-preview.31
            commhub-server 0.9.0-preview.29

Fact-1 pairing caveat also still holds (通信龙 tested 2026-08-17 evening):
main-source constant `OPENCODE_AGENT_NODE_VERSION` = agent-node preview.31,
but the published preview.39 binary embeds a `.d.ts` pair naming
agent-node preview.28. They installed preview.28 to get opencode-指挥狗
to start.

---------

Co-authored-by: t <t@t>

* docs(refresh): Q2 anchors for password + #450 + Fact-2 (#895/#896) notes (#900)

Follow-up to PR #898 (task 27faa700). Doc-only; 5 files, +20 -7.

## Anchors nailed via git log -S / gh issue view (2026-08-18)

### 1) README.md:50 + README.en.md:50 — 一次性随机密码 anchor
Introducing commit: `3e4e190c` (PR #264 fixing #261 P0-2), merged
2026-06-28. First npm-published preview containing the behavior:
`@sleep2agi/agent-network@2.2.22-preview.4`. All subsequent
`2.3.0-preview.0..39` inherit.

Stable `@latest` at the time of writing is `2.2.21` — pre-dates the
fix. So @latest users still get the fixed default `admin` / `anethub`.
Older preview `≤ 2.2.22-preview.3` also pre-dates the fix.

Rewrote both README lines to state the anchor version, PR/issue links,
and the explicit "you are still on the fixed default if you're on
@latest 2.2.21 or preview ≤ 2.2.22-preview.3" caveat.

### 2) docs-site/docs/{,en/}guide/getting-started.md:97 — #450 anchor
Issue #450 is **OPEN** (verified `gh issue view 450`). Root fix landed
in PR #239 commit `1eff3a4d` on 2026-06-28. Vincent's 2026-08-09 audit
verified the fix in an isolated Docker probe on
`agent-network@2.3.0-preview.38` reaching SSE connected.

But #450 is not closed because four acceptance gates need to run
green before promoting to latest:
  1. promote a release containing 1eff3a4d to npm latest and repeat
     the cold-install journey against @latest
  2. exercise a >60s cold fetch scenario
  3. verify actionable DNS/registry/timeout/version failure
     classification
  4. run the credentialed idle → send_task → non-empty reply layer

So: current `@preview` (2.3.0-preview.39) has the fix; `@latest`
(2.2.21) still ships the bug. Rewrote the warning block on both zh
and en getting-started to name that split explicitly and provide the
workaround.

Method-note (for the audit trail): PR #239's title only names #237,
not #450 — so the standard `gh pr list --search "fixes:#450"` returns
nothing. The link is only recoverable via `git log -S` on the error
string. Worth remembering.

## Fact-2 notes (通信龙 D1-D3) — #895 / #896 in main NOT yet in npm

- PR #895 (`f565e9b8`) fixed `anet node start` false-`✅` /
  false-`started detached (tmux session live)` in detached scenarios.
  Merged to main.
- PR #896 (`40574a02`) fixed `anet project up / project restart`
  exit-code lie. Merged to main.

**Neither has been cut into an npm release yet** — so for anyone on
`@preview` (currently `2.3.0-preview.39`), the trap still exists. The
real check remains `tmux has-session -t "=<alias>"` — the `=` is
required (bare alias is a prefix match and can go green on the wrong
session).

Added this note in three places:

1. `README.md` + `README.en.md` quickstart — right after
   `curl /health` verify, before the "open localhost:3000" line, so
   the first-run reader sees the caveat while their brain is still
   on `anet node start`.
2. `docs-site/docs/deploy/clean-server.md` §7.1 — right below the
   `tmux new -s anet-<alias> + anet node start <alias>` recipe.
3. `docs-site/docs/deploy/clean-server.md` §故障排查表 — added a
   new row 5.5 (`✅ printed but tmux session not there`) with the
   diagnostic recipe.

Not touched (per current scope):
- `docs/version/0.11.0/feature-audit.md:33` (D4) — the audit already
  correctly reports the bug and its `✅` refers to `anet node create`
  (wizard), not `anet node start`; no misleading claim to correct.
- The 6-10 `anet node start` command demonstrations in
  `docs-site/docs/deploy/npm.md`, `.../concepts/networks.md`,
  `.../concepts/tokens.md` — those are pure command samples with no
  success-criteria text; the central note in clean-server.md is
  where they land.

## Not touched — Q2 leftovers (independent follow-ups)

Sixteen other Q2 lines identified in the exhaustive scan still carry
`TBD 追溯` anchors (multi-model.md, agent-node.md, batch.md,
dashboard.md, feishu.md, runtimes.md, upgrade.md, RELEASE-SOP.md,
etc.). Each needs a targeted `git log -S` pass. Scheduled for
subsequent follow-ups so this PR stays reviewable.

## Verification

```
$ git diff origin/main...HEAD --stat
 README.en.md                               | 8 ++++++--
 README.md                                  | 8 ++++++--
 docs-site/docs/deploy/clean-server.md      | 7 ++++++-
 docs-site/docs/en/guide/getting-started.md | 2 +-
 docs-site/docs/guide/getting-started.md    | 2 +-
 5 files changed, 20 insertions(+), 7 deletions(-)

$ git grep -c '2.2.22-preview.4' README.md README.en.md
README.en.md:1
README.md:1

$ git grep -c '1eff3a4d' docs-site/docs/{,en/}guide/getting-started.md
docs-site/docs/en/guide/getting-started.md:1
docs-site/docs/guide/getting-started.md:1

$ git grep -c '#895' README.md README.en.md docs-site/docs/deploy/clean-server.md
README.en.md:1
README.md:1
docs-site/docs/deploy/clean-server.md:2
```

## Version facts sourced from npm (2026-08-18)

Verified `npm view <pkg> dist-tags` on 2026-08-18 — unchanged since
2026-08-14 authoring pass:

  latest  : agent-network 2.2.21 / agent-node 2.4.13 / commhub-server 0.8.8
  preview : agent-network 2.3.0-preview.39 / agent-node 2.5.0-preview.31
            commhub-server 0.9.0-preview.29

These are snapshots, not promises. Both `@latest` and `@preview` tags
keep drifting; re-read via `npm view <pkg> dist-tags` before quoting
elsewhere.

Co-authored-by: t <t@t>

* fix(tests): derive the opencode pair versions from source instead of pinning them in tests (#902)

Found by running the RELEASE-SOP pre-flight for preview.40 rather than by
reading it. `scripts/sync-pinned-versions.sh` bumps
`OPENCODE_AGENT_NETWORK_VERSION` / `OPENCODE_AGENT_NODE_VERSION`, but it does not
touch the tests — and two suites hard-code that pair in nine places:

  test386  5 assertions + 3 fixtures (bin/npx spec, two package.json versions)
  test384  run.sh defaults + Dockerfile ARG defaults

The sharpest one is test386:398. It `grep -Fq`s the exact install command that
`opencodeExactPairInstallCommand()` builds FROM those constants, so bumping a
constant makes the assertion fail by construction. Following the SOP as written
produces a red, and the cheapest way to make that red go away is to edit the
number in the assertion — which turns the test into a copy of the current value
that checks nothing.

Everything now reads the constants at run time, fail-closed: if the source file
cannot be parsed the run fails rather than continuing with an empty string,
because `grep -Fq ""` matches everything and would silently turn five assertions
into permanent passes. The two fixture package.json files are rewritten by
run.sh before use and carry a note saying not to chase the constant by hand.

Two traps hit while doing this, both worth recording because the fix and the
no-op look identical:

  * test384's Dockerfile sets `ENV *_UNDER_TEST=${ARG}` from an ARG that had a
    hardcoded default, and run.sh reads `${*_UNDER_TEST:-<derived>}`. With a
    non-empty ARG default the ENV is never empty, so the derived branch could
    never run — the "now it derives" change would have been inert while the
    suite kept passing against the previous version. The ARG defaults are now
    empty so `:-` reaches the derivation; `--build-arg` still overrides.
  * bin/npx compared against a literal spec. It now compares against
    `$EXPECT_NODE_SPEC` exported by run.sh and exits 65 if that is unset, rather
    than falling through to "unexpected npx arguments" — which would have read
    as a product failure instead of a harness one.

RELEASE-SOP gains a calibration note saying these files are deliberately NOT in
the Live versions table: they are self-consistent now, and registering them
would re-introduce a second copy to drift.

Co-authored-by: t <t@t>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(docs): changelogs must not line-anchor into main — the anchor rots by construction (#903)

A changelog entry describes a state that was true at some past release. A
`blob/main/<file>#L<n>` link resolves against today's code. Those two facts are
incompatible: the link is wrong after the next commit that touches that file,
and nothing reports it.

Measured, not assumed. Two of the six such links in the changelogs:

    cli.ts#L61    documented as `PINNED_SERVER_VERSION`
                  now lands on  `} from "../src/opencode-preset";`
    cli.ts#L2589  documented as the `bunx --bun @sleep2agi/commhub-server@…`
                  line in `anet hub start`
                  now lands on  a line of `anet project restart` help text

Both now link the file without the anchor and name the symbol instead, which is
what a reader can actually search for. The original line number is kept in
parentheses as historical context — it was true when written, and saying so is
more useful than deleting it. This follows the precedent RELEASE-SOP records at
R367, which replaced `cli.ts:NNN` references with symbol references for the same
reason.

Scoped to changelogs on purpose. `docs-site/docs/api/mcp-tools.md` carries 44 of
these anchors and all 44 are still in range, landing on plausible content — they
are maintained, because that page documents current behaviour rather than past
releases. A guard reddening on ~100 maintained links would be a backlog canary
that dies the day the backlog clears, and would train people to ignore it.

check-docs-integrity.py gains the rule, exercised three ways: repaired tree →
exit 0 (2 changelogs, 0 anchors), f565e9b8's changelog → exit 1 naming each,
CHANGELOG_GLOB pointed at a missing filename → exit 2 rather than a clean pass
against nothing.

Co-authored-by: t <t@t>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* feat(hub): let get_all_status filter by alias, and say what its summary counted (#904)

`get_all_status` returns one row per session with 31 columns and no way to ask
about specific nodes. On this hub — 222 sessions — the response is about 259 KB,
past what an MCP client takes in one result. So the caller who wanted the status
of THREE nodes could not get it from this tool at all, and had to go around it
to the REST API. That is the whole of #824, hit first-hand.

`filter_alias` takes one alias or several separated by commas, matched exactly
through bound parameters. The patrol loop still gets everything, so the argument
is optional and nothing existing changes.

Blank entries are dropped rather than matched. A trailing comma would otherwise
produce `alias = ''`, which matches no row — and "no rows" reads exactly like
"those nodes do not exist". The failure and the true answer would be
indistinguishable to the caller. That behaviour is the reason the parsing lives
in its own module with tests rather than inline: nine cases pin it, including
that placeholder count always equals alias count so parameters cannot misalign,
and that a filter of only commas means "no filter" rather than "match nothing".

The response also now carries `summary_scope` and `sessions_returned`. `summary`
has always counted every session in the read scope while ignoring the filters,
which is right for the patrol loop — but a caller who asked about three aliases
and gets back three rows plus `idle: 96` can easily read the 96 as being about
their three. Rather than change the semantics under existing callers, the
response says what the number covered.

Verified: the wiring assertion fails against main and passes here; the project's
own runner reports server/src/alias-filter.test.ts pass=9 fail=0.

Pre-existing and NOT from this change: `server/src/task-lifecycle-watcher.test.ts`
fails on main today ("startHub owns a live watcher timer instead of relying on
import side effects", expected 0 received 1). Confirmed by running that file
against main's tools.ts in this same tree — identical failure. Aggregate is
937 pass / 1 fail either way.

Co-authored-by: t <t@t>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(hub): PORT=0 must mean an ephemeral port, not the production Hub port (#906)

`server/src/server.ts:51` read

    const PORT = Number(process.env.PORT) || 9200;

`Number("0")` is `0`, which is falsy, so `PORT=0` — the conventional way to ask
the OS for an ephemeral port — silently resolved to 9200, the production Hub
port. Three consequences, and the middle one is the worst:

  1. On a host where 9200 is taken (a running Hub), a test that sets PORT=0 dies
     with EADDRINUSE and reads as a product bug.
  2. On a host where 9200 is FREE, that same test PASSES — by binding 9200. It
     is green because it grabbed the production port, not because PORT=0 did
     anything. Green for the wrong reason is worse than red.
  3. Anyone asking for an ephemeral port gets the production port instead.

This is not hypothetical. `server/src/task-lifecycle-watcher.test.ts` fails on
main today, and that is why: it spawns the Hub with `PORT: "0"`, the child binds
9200, 9200 is already in use on this machine, the child exits 1, and the
assertion `expect(child.exitCode).toBeNull()` fails. The test reports "the
watcher did not stay alive" and says nothing about ports — the message points at
the wrong layer entirely.

The file already knew. `bootServer` uses `opts.port ?? PORT` with a comment
saying `||` "would swallow a legitimate 0". The correct rule was one level above
the line that needed it.

`resolvePort` also rejects a malformed value instead of defaulting. Falling back
to 9200 on `PORT=abc` means a typo starts the server somewhere the operator did
not ask for, and on this fleet that somewhere is production. Parsing is decimal
digits only after trimming: `Number()` alone accepts `"0x10"` as 16, so a value
that does not look like a port would still resolve to one, quietly and to a
different number than was typed.

Verified as an A/B on the same tree, same DB layout, cwd at the repo root:

    main's server.ts   4 pass, 1 fail   (EADDRINUSE, child exit 1)
    this branch        5 pass, 0 fail   (child binds 41885 and stays up)

The project's own runner now reports 946 pass / 0 fail / bad=false. Before this,
it was 937 pass / 1 fail.

Co-authored-by: t <t@t>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(tests): outbound 工具集断言改为从真相源派生(#816 —— 门是错的,而且没人跑) (#905)

* fix(tests): assert the outbound tool set from the source of truth, not a stale copy (#816)

test235's harness asserted the outbound MCP surface as a hard-coded array of
three names. `OUTBOUND_TOOL_NAMES` in node-server.ts has held FOUR since
`commhub_upload_file` shipped in #693, so that assertion has been wrong on main
— and nothing reported it, because no workflow and neither of qa.sh's L0/L1
lists runs test235. A gate that is wrong and unrun is indistinguishable from a
gate that passes.

Third instance of the same shape tonight, after qa.yml's path filter missing the
tests it runs and the two orphaned verify scripts: the check exists, it is
plausible to read, and nothing invokes it.

The names now live in `agent-network/src/outbound-tool-names.ts` and both sides
import them. That module exists as its own file rather than an export from
node-server.ts for a measured reason: importing node-server.ts to read a
constant BOOTS THE SERVER.

    $ bun -e 'import { OUTBOUND_TOOL_NAMES } from "./src/node-server.ts"; …'
    [commhub] MCP stdio connected
    [commhub] starting SSE listener...
      OUTBOUND_TOOL_NAMES: commhub_get_all_status, …

A harness that opens a live MCP connection to read a list fails for reasons that
have nothing to do with what it tests. I hit that while writing this fix.

The assertion also sorts both sides. It is about WHICH tools are exposed, not
about the order the server registers them in, and an order-sensitive comparison
would have turned a reordering into a mystery failure.

Both new assertions fail against main. Suite 497 pass, tsc clean.

One note on the credit: I first reported this issue as not reproducible. My grep
searched for the NEW tool names, so of course it found nothing — the assertion
pins the OLD three and never mentions upload_file. 通信团队's triage node caught
the mistake and pointed at socket-harness.ts:210. Probe for the assertion, not
for the symptom.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tests): scope the cross-package assertion — it fails ENOENT inside the unit image

My own CI break, one commit old. The new test read
tests/test235-grok-mcp-outbound-only/socket-harness.ts, but
tests/test745-agent-network-unit-ci/Dockerfile copies ONLY agent-network/ (plus
agent-node/package.json and its own run.sh). So the assertion passed on a full
checkout and failed with ENOENT in the container — the same "works where I ran
it, not where it runs" shape I spent tonight cataloguing, this time in a test I
wrote to catch that shape.

The harness assertion now skips when the file is absent. Skipping is fail-open,
so it is paired with an assertion that refuses to let the skip be silent: on a
full checkout the harness MUST exist (its absence there is a real regression),
and in a package-scoped image the run prints which assertion did not execute.
A green in that image is therefore never mistaken for "the harness was checked".

Verified both ways:

  full checkout                  5 pass, 0 skip   (assertion really runs)
  simulated package-only image   4 pass, 1 skip   + "the socket-harness
                                 assertion did NOT run in this image"

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tests): stop detecting the checkout shape — assert only what is in this package

Third attempt at the same 20 lines, and the first two were both wrong in the
same way.

  1. Read tests/…
vansin added a commit that referenced this pull request Aug 18, 2026
* docs: 开 PR 前的自查清单 —— 钉当天真实翻车案例,不写通用建议

2026-08-13 一天九条 PR,其中四条的主要内容是在修自己前一轮的东西,
而四条里有三条是外部审先发现的,不是自查出来的。

把那几条的根因写成清单,每一项都钉真实案例:
1. 证据锚点必须是被测代码那一版(假锚点:92d 那版没有产出该报告的代码)
2. 验了零件还要验装配(推导出的参数名 7/7 正确,但 pipefail 打死了 runner)
3. 调用方要遵守被调用物自己写明的契约(安全套件缺 --network none,
   带与不带都打印「网络已禁用」)
4. 判据范围要与被判对象一致(元门按前缀判覆盖,runner 用 -maxdepth 1)
5. 断言要精确到不合规会被拒绝(子串检查、正则缺结尾锚定,各放行过一次)
6. mutation 要跑到曾经活下来为止,并把活下来的写进报告
7. 没有对照的探测结论可能整个反过来(404 那次:不存在的路径返回 401)
8. 分清「它坏了」和「我用错了」(4 条失败挂了四轮,其实是我违反了契约)

外加当天各栽过一次的三个 shell 坑:反引号吞证据、reset --hard 冲掉未提交改动、
嵌套 heredoc 同名标记。

* docs: 清单加第 8 条 —— 临时写的扫描器要用已知阳性+已知阴性校准

一天之内我自己写的一次性检查脚本报了三次假阳性,根因都一样:
扫描器的范围和被扫对象的实际形状不一致 —— 就是第 4 条,
只不过犯在检查器上而不是被检查物上。

  「锚点是否等于 HEAD~1」→ stack 分支里有三份报告,head -1 抓错文件
  「docs 引用了 main 上不存在的路径」→ 正则把 docs-site/docs/… 截短
  同上 → 报的是 PR 自己新增的文件

这类脚本没人审、跑一次就丢,是最容易出错又最容易被信的东西。
所以要求两条,缺一不可:构造一个确信应命中的输入验阳性,
构造一个确信不该命中的输入验阴性。

只验阳性会漏掉「什么都命中」的扫描器;只验阴性会漏掉「什么都不命中」的 ——
而后者看起来就是一片绿。

原第 8 条顺延为第 9 条。

* docs: 第 8 条补一句 —— 校准输入要覆盖数据里真实出现的形状

用第 8 条重扫自己六条 docs PR 时当场验证了它的边界:

我拿「纯路径」构造了已知阳性和已知阴性各一条,两条都通过。
但实扫结果里仍有两个假阳性 —— 它们是 GitHub URL 里的路径片段
(https://github.com/…/blob/main/server/src/auth.ts),
而我的校准输入里根本没有 URL 这种形状,所以校准放行了这个盲点。

补一句:校准证明的是「在你构造的那几种输入上它是对的」,不是「它对」。

顺带记录这次重扫的完整结论:六条 docs PR 里
- 三处命中全部核过,都不是缺陷(两处是 GitHub URL,指向的文件在 main 上确实存在;
  一处是本清单自己在引用先前那个假阳性作例子);
- 已知阴性在 pre-pr-selfcheck 分支上被"误报"也核过了:
  那是因为本文件的正文里就写着那个路径字面量,属于校准输入与真实内容撞车,
  不是扫描器出错 —— 校准把「这一分支上该测试无效」这件事本身报了出来。

* docs: 附一节「一道门可以怎样失效」—— 四种失效方式,每条对应一个真实 issue

正文九条是「开 PR 前查自己」。这一节是「审一道门时查它」,
把只读审计 main 上 20 道 CI 门时用过的角度收敛下来,每条都能照着复现:

一 门根本没跑        —— #818(三道带 mutation 的门自己的目录不在触发路径里)
二 跑了但跑得比声称的少 —— #817(删 40/46 个测试文件,门仍 RESULT: PASS)
三 断言收得下不合规    —— mutation 命名断言未锚在 (fail) 行,A/B 实测松版仍 PASS
四 结论建立在从不检查的前提上 —— #814(声称「网络已禁用」,但那是硬编码文案)

外加容易与之混淆的第五种:被测代码根本没有测试直接覆盖(#819),
并写明判据必须收紧到「有测试真的 import 它」,只按名字 grep 会把注释算进去。

明确写了两条边界:
- 只有语料型门需要分母,场景/契约型门没有分母是正常的,别拿第二条去修它们;
- 四种失效互相独立,查过一种不代表另一种不成立 ——
  test224 同时中了第一和第四种,我自己的 test798 一度同时中了第二和第三种。

* docs: 清单加第 9 条 —— 先搞清计数的单位,再谈趋势

我在 hub 日志里 grep 一个字符串数出 1018 条,画了小时曲线(6→45→67),
据此报了「舰队级问题,而且在上升」。拆开看之后那个量级是假的:

  954 条写的是 recorded **1** delivered-stale(61 条写 2,3 条写 3)
  相邻间隔 30/80/95/110/175/200 秒 —— 是巡检周期在重报
  同一文件里还有另一种行 `[patrol] expired N stale task(s)`,共 109 次,
  跨约 15 天 ≈ 0.3 次/小时,低而稳定的背景率

所以「67 条/小时」不是「每小时 67 个任务出问题」,而是
「那一小时里几乎每次巡检都发现有一个任务卡着」—— 同一个任务卡得久。

两层错:
1. 只 grep 了一个字符串,没先看这个来源里一共有几种行;
2. tail -3 看到 3 条就当成总共 3 次(实际 109),把窗口当成了全集。

自查三问:一条记录 = 一个什么(事件/对象/某次轮询时该对象仍处于某状态)?
这个来源里还有别的行吗(先把种类 uniq -c 列出来)?我数的是全集还是窗口?

并写明:重报型计数的「上升」往往只说明状态持续更久,不是发生更多 ——
趋势比绝对值更容易骗人。

顺带修正上一版插入时的两个结构问题:编号从 8.5 改为 9(原 9 顺延为 10),
「我错在哪」「自查」两处从同级标题降为正文强调。

* docs: 第 1 条补上「什么时候取那个 SHA」—— 同一根因已发作两次

原第 1 条只说了「锚点必须是被测代码那一版」,没说**在哪一刻去取它**。
结果同一根因发作了两次:

  一次 ref 错:传的是 $(git rev-parse origin/main) —— 那是主干不是分支(被独审抓到)
  一次时机错:传的是 $(git rev-parse HEAD),但在 git commit **之前**取 ——
              镜像里跑的是工作区(含改动)的代码,戳进日志的却是改动前的 SHA(自查抓到)

第二种尤其阴:证据本身有效(跑的确实是新代码),但它自称的锚点指向一个
不含该改动的提交;校对时表现为同一份报告里出现两个不同的 source_commit,
一个在抬头(对),一个在嵌入的运行输出里(错)。

补进固定顺序:先 commit → 再 git rev-parse HEAD → 再 build/run → 报告作为
report-only 子提交落在该 SHA 之上;并给一行自检:
grep -oE 'source_commit=[0-9a-f]{40}' <报告> | sort -u 应当只输出一个值。

* docs: 第 1 条的规则是错的 —— 锚点该等于 SRC,不是等于 HEAD

审查(通信狗 FINAL MAJOR)指出 §1 自相矛盾:一边说报告作为 report-only
子提交落在 SRC 之上,一边自查却要求锚点「等于 git rev-parse HEAD」。
报告提交后 HEAD 合法地高于 SRC,stack 分支同理 —— 这条规则会把正确的
做法系统性判成假红。

这条尤其该修:我自己在 #835 上用的正是 report-only 子提交模式。
按第一版的规则,我的清单会否掉我自己的正确做法。

改成正确的不变量:
  锚点 == SRC(建镜像时传进去的 build-arg,即被跑的那份字节)
  且 git merge-base --is-ancestor "$SRC" HEAD

同时把最后一行判据的强度说清楚:git show SRC:<文件> | grep -c 只证明
「那一版的代码能产出这种输出」,是必要非充分 —— 不证明这一次的输出
就来自它。要证 provenance 得靠镜像与字节的绑定(例如把被测文件在该
commit 下的 blob 哈希传进容器里重算比对),而不是靠字符串出现过。

另修一处错链:§3「它自己通常不会检查这些要求(见第 8 条)」——
第 8 条讲的是扫描器校准,该指的是「失效四:门的结论建立在一个它
从不检查的前提上」。已改。

* docs: 四条 MINOR —— mutation 停止条件、109 的单位、分母范围、import 的强度

① §6「跑到曾经活下来为止」容易读成「见到一条活的就停」。写死停止条件:
   四个维度各一条、至少一条活过、活过的要么补红要么写进报告说明为什么不补、
   补完重跑全部。并加一条:删改测试文件的 mutation 必须在隔离工作区做,
   跑完校验原文件哈希未变 —— 在共享 checkout 里直接删测试,中途失败会把
   删除留在别人的工作区。

② §9 的 109:单位是日志行,不是独立任务数。那行里的 N 可以大于 1,同一任务
   也可能被不同巡检再次计入。我把它从原来的读法修正过一次,但仍不是任务数。
   这条本身就是该节的例子:一个数字被修正过一次之后,很容易被当成已经准确。

③ 「只有语料门需要分母」过满。契约型门也有自己的枚举面(端点集、工具名集、
   profile 集),一样是分母、一样会数错。判据是「这道门声称覆盖了一个集合吗」,
   不是「它遍不遍历文件」。
   「import 即覆盖」同样过满:import 只把判据从"提到过"收紧到"用到过",
   离"验过"还差一层 —— 测试可以 import 一个模块却只拿它当 fixture。

* docs: 第 11 条真的写进文档了;mutation 停止条件不再要求"必须有活下来的"

两条审查意见,都成立,而且都是我上一轮刚写进去的:

① 我在 §6 里引用「本清单第 ⑪ 条」,而这份文档只有 10 条 —— 第 ⑪ 条只存在于
   我在 PR 上的一条评论里,从没写进正文。
   我在一份讲「指针要指得到」的清单里,造了一个指不到的指针;而且它同时是
   「发现留错了载体」那条 —— 内容我写过,只是留在了会滚走的地方。
   修法不是删引用,是把那一条真的写进来(§11:批量替换前证明锚点唯一、
   提交前做规模体检,含当天 355 → 158,627 行那次事故与事后同类排查)。

② §6 的停止条件写了「其中至少有一条活下来过」。那意味着一道所有 mutation
   都正确变红的强门,永远无法满足停止条件 —— 规则把做对的人挡在外面。
   改成:活下来的要补红或写明为什么不补;四条全红不是不合格,但要在报告里
   写清楚选了哪四个维度,让复核的人能判断这四个够不够宽。

这是同一天里第二次犯"规则太严会伤好人":上一次是 §1 要求锚点等于 HEAD,
会把合法的 report-only 子提交判成假红。两次都出在我自己新写的规则上。

* docs: 八条审查意见 —— 配方本身会产出假清单,以及五处表述过满

最刺的一条(:212):§9 教人"先看这个来源里有几种行",而我给的配方是
`grep -oE '<共同前缀>[a-z -]*'`。真实前缀是 [patrol],在 ERE 里那是字符类。
实测产出 patrol×3 / pired×2 / tale task×2 / recorded×1 —— 一份看起来像模
像样、实际全错的种类清单。而本节的全部要点就是先搞清有几种行。已改成
转义写法并附实测对照。

其余七条:
- :317 换锚点必须同时换 grep 模式。原式 grep -Fq 把 ^ 和 \( 当字面量,
  默认 BRE 又把 \( \) 当分组;^\(fail\).* 只在 -E 下是想要的意思。改了锚点
  不改 flag,断言会恒真或恒假 —— 恒真正是那一条要消除的。
- :56 本仓 Docker 访问经组切换授予,固定顺序配方要写成 sg docker -c,
  否则照抄会以"连不上 daemon"失败。
- :20 只验 SRC 是 HEAD 祖先不够:SRC 之后若提交了源码/测试/Dockerfile,
  祖先关系仍成立而 PR HEAD 的字节从未被跑过。加一条 git diff --name-only
  SRC HEAD,中间只能是 report。
- :31 源码 grep 既不充分也不必要:输出可能由多片段拼装、来自变量、由
  import 的 helper 发出、或由被测文件调起的另一个命令产生。降级为启发式。
- :158 §7 那次 404 是负对照不是阳性对照(拿一个确信不存在的路径去探),
  例子挂错了位置。
- :336 import 仍过满:未使用的 import、类型导入照样被扫描器看见。

按本清单第 11 条:六处替换在脚本里逐个断言了锚点唯一;提交前做规模体检
(344 → 360 行,新增关键句各 1 次,最高重复非空行未异常)。

* docs: 更正「Docker 命令必须走 sg」—— 那句话对我自己这台就不成立

上一轮我照审查意见把固定顺序配方改成 sg docker -c,并注明"本仓通过组
切换授予 Docker"。这轮实测自己的环境,那句话不成立:
  id -Gn 含 docker  → 我在组里
  docker ps 可用    → 裸命令就行
  sg docker -c 也可用 → 只是无害的多余一层
也就是说,我接受了一条审查意见并写进文档,却没有验证它是否适用于我自己
正在用的环境 —— 而我这两天反复在写"接受一条意见和验证它是两件事"。

同时另一台环境给出了第三种情况:sg docker -c 同样失败,因为那台连 docker
组都没有(对 #828 的一次实现回应就卡在这里,它的 Docker 契约套件因此从没
跑过,而回应正文读起来像是已完成)。

所以判据不是"用不用 sg",是先确认这台怎么授予:
  在 docker 组      → 裸 docker 即可
  不在组但可用      → socket 权限或 rootless
  跑不了            → 你写的 Docker 门在这台根本不会执行
第三种最要紧:这时唯一正确的做法是如实标注它没跑过,而不是把它当成已验证
的证据交付。

* docs: §1 要求的是换提交结构,不是更小心 —— 而现有做法 0/14 是那个结构

我在 §1 里把"报告作为 report-only 子提交落在 SRC 之上"写得像既有约定。
它不是。实测 main 上 14 份 docs/tests/report*:
  纯 report-only 提交         0 / 14
  与代码混在同一提交          14 / 14

而混在一个提交里,锚点在结构上就不可能等于被测那一版 —— 一个提交装不下
自己的哈希。抽样验证:report-546-… 落在 c527a3b,报告内嵌 SHA 是
b7d1289,是该提交的祖先,指向"改动之前"的代码。

所以 §1 开篇那个 bug(报告锚点指向一个不含该改动的提交)不是一次疏忽,
是现有提交结构的必然产物。要根治只有两条路:报告单独成子提交落在被测
提交之上;或者接受锚点指向父提交、但在报告里显式写明这一点。
两条都行,但必须选一条并写出来 —— 默认什么都不说时,读者无法区分
"锚点指向父提交是因为结构如此"和"锚点钉错了",而两者严重性完全不同。

这条是顺着"我接受了别人的前提却没验"排查出来的:代码类断言我都验了,
环境/约定类断言我照单全收。这是第三条同类(前两条是 sg docker、
consumed_at 的通道限制)。

* docs(selfcheck): 加 §12 —— 改既有套件前先查它是不是已经有一份被跟踪的报告

同一条审查意见一天内提了三次(#841 / #842 / #844)。三次都是:我改动了某个
套件实际跑的东西,但 docs/tests/report-test<N>*.txt 还记着改动之前那次跑的
SOURCE_COMMIT 与结果。

连着犯三次的原因不是「不知道要留报告」——新建套件时我会想到,因为报告是我
从零写的;改既有套件时那份报告已经在仓里、我根本没去看它。盲点在「有没有
意识到已经有一份」。

三次的共同点是:没有一次我改了 run.sh。判断标准不是「动没动这个套件的目录」,
是「这个套件下次跑,看到的东西会不会不一样」——
  #841 以为只改 Dockerfile 一行,实际换掉了 agent-node 的整个依赖图
  #842 以为只改一个 lockfile,实际换掉了 test745 装到的 hono 版本
  #844 以为只改两个 md,实际改了 test831 扫到的 pin 数与断言预期值

另记一条更难看的:我在 #842 的回复里写「已记进 docs/pre-pr-selfcheck.md 的
待办」,那句话当时是假的,我没写。是第三次时回头核自己说过的话才发现。
汇报里出现「已记进某文档」时,要去把那个文档打开看一眼。

* docs(selfcheck): 加 §13 —— 每一个「范围」都要先证明再使用

§11 是这一条的一个特例,而我在写完 §11 之后又栽了三次,因为 §11 只写了
「批量替换」这一种形态。共同点是一句话:我操作的范围,比我以为的宽或窄,
而两者打印出来一模一样。

已经发生过的七种形态,每条都是实例:
  替换的匹配范围 —— 全局 replace 连正确的两处一起改了
  正则的行内范围 —— 「在 x 段搜」按整行匹配,给同行另一个锚串套上别人的限定,
                    自己造出一条假告警
  窗口的方向     —— 限定语只在锚串之前找,英文写法在后面,漏判
  字符类排除     —— 抓锚串的正则排除了反引号,漏掉带反引号的那几条
  扫描的树       —— 在老分支上 grep server/src(20 个 .ts,main 上是 106)
  输出的截断     —— head -N 截掉了后面的发现(当天四次)
  计数的单位     —— 把「(pin,文档) 对数」当成「引用总数」,139 vs 141

为什么这类错难自己发现:范围错的输出和范围对的输出长得一模一样。漏掉三条
不会报错;多改两处替换照样成功;在老分支上 grep 出 0,和真的 0,都打印一个 0。
所以它不能靠看结果发现,只能靠先证明范围。

最省事的经验:同一份数据用两种方法各量一次,数字对不上就说明至少有一次范围
错了。当天三次都是这么发现的 —— 139 vs 141、24 vs 27、中文有 mismatch 而英文
没有。如果我只量一次,三次都会当成正确结果发出去。

* docs(selfcheck): §13 补上替代写法 —— 只描述症状的规则拦不住我

§13 第一版只列了七种形态,没给任何替代命令。结果写完当天我又在其中一条
(输出的截断)上栽了两次:一次把门的 FAIL 列表看成 3 条实为 4 条,一次把
坏掉的管道读成文件里没有。

「知道会漏」和「手边有个不漏的写法」是两回事,所以补两条具体做法:

1. 看检查器输出别用 head/tail,用 sed -n '/FAIL/,/^$/p' 按标记段落取,
   或全打之后单独挑计数行断言。实测对照:同一段输出 tail -5 丢掉标题和
   第一条,sed 段落取法完整。

2. 搜索命令别吞 stderr。实测 git ls-files | xargs command grep 并不静默 ——
   rc=127 且 stderr 写着 xargs: command: No such file or directory
   (command 是 shell 内建,xargs 找不到这个可执行文件)。是我自己加的
   2>/dev/null 把它变哑,于是空 stdout 被读成文件里没有。
   替代:xargs grep / git grep -ln / 需要判前段退出码时用 PIPESTATUS。

推广一条:任何用来证明某物不存在的命令,先在一定有命中的输入上跑一次 ——
这是 §8 的已知阳性,但这里的阳性验的是管道本身通不通,不是判据对不对。

* docs(selfcheck): 加 §14 —— 解冲突时公共上下文可能属于双方

案发:我给出的合并解法(冲突块取并集)公开发在 PR 上,一轮之后干跑才发现它会
产出一个看起来正常、实际有一个 CI job 永远不执行的文件。

根因:两个新增 job 的 runs-on / timeout-minutes / steps: / checkout 共 5 行逐字
相同,git 把它判成公共上下文只留一份 —— 但它语义上属于两个 job 各一份。所以错
不在 <<< === >>> 之间,而在那之外。并集只处理标记之间的内容,那份共享文本被留给
了后面那个 job,前面那个于是只剩一行 name。

为什么难发现:
  一、它是合法 YAML。GitHub 接受,job 名字出现在检查列表里显示绿色 ——
      一道从不执行的门,和一道执行且通过的门,在 PR 页面上长得一模一样。
  二、换一个合并顺序坏的是另一个 job,两种互为镜像,看起来像顺序敏感,
      实际两种都是坏的。
  三、当时仓里所有门都是绿的,因为没有任何门检查 workflow 文件本身
      (实测 .github/、scripts/、tests/ 里 yamllint/actionlint 命中 = 0)。

正确解法:每个结构 = 自己的头 + 公共块(复制一份)+ 自己的体;而列表型的冲突块
(on.paths)取并集仍然对 —— 同一次冲突里不同的块要用不同的解法,不能一刀切。

自查:解完不要只 grep 残留的 <<<<<<<,要做结构断言(每个 job 断言 runs-on 非空、
steps 非空)。坏掉那版能通过 yaml.safe_load,只有断言拦得住。

这条不只发生在 YAML:任何两个结构相邻、样板逐字相同的文件都会这样。样板越标准,
git 越可能把它判成公共上下文,而它越可能属于双方。

它和 §13 是同一族 —— 我以为的操作范围是标记之间,实际范围包含标记之外的共享行。

* docs(selfcheck): 加 §15 —— 用仓库不使用的约定去度量,会得到零信息的结论

案发:评估要不要把 npm latest 从 0.8.8 提到 0.9.0 时,我量了两个标准指标 ——
窗口内 BREAKING CHANGE = 0、标题以 Revert 开头 = 0。读起来是「这 50 天没有破坏性
变更、也没回滚过」,一条支持提版本的强证据。

发出去之前回头验:本仓全历史 2222 个提交里,这两个约定各出现 0 次。也就是说这个
仓库从来不用它们,我量的是「一个不存在的约定没有出现」。

为什么这类错危险:
  一、它伪装成好消息。「没找到破坏性变更」和「没有破坏性变更」在句子里差一个词,
      在结论里差一个世界。
  二、它偏向你希望的方向。我当时在评估该不该提版本,而「零破坏性变更」正好推着
      往前走 —— 用不适用的指标得到的空结论几乎总是站在你原本倾向的那一边,
      因为你不会去质疑一个支持你的数。
  三、它看起来很专业:引用通行约定、给确切数字、可复现,每一条都对,只有前提错了。

自查:想用某个指标之前,先在**全历史**上确认这个仓库用不用它。只看窗口的话,
「窗口内 0 次」和「这个仓从来 0 次」打印出来是同一个 0。

与已有条目的关系:§8 是扫描器范围与被扫对象不一致,§13 是操作范围比以为的宽或窄,
这条是指标本身在这个语境里不存在 —— 前两者算错了,这一条根本没在算。

该怎么办:不知道就说不知道。那次评估我最终写的是「不是没有破坏性变更,是用现有
提交记录看不出来有没有」,然后把判断交给一次真实的升级演练。承认某个问题用手上的
方法答不了,比给一个空的答案有用。

---------

Co-authored-by: vansin <smartflowaiteam@gmail.com>
Co-authored-by: t <t@x>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants