Skip to content

ci: 元门 —— 新增测试文件不能落在所有聚合门的扫描范围之外(依赖 #798 #800) - #801

Merged
vansin merged 13 commits into
mainfrom
ci/test-coverage-meta-gate
Aug 17, 2026
Merged

ci: 元门 —— 新增测试文件不能落在所有聚合门的扫描范围之外(依赖 #798 #800)#801
vansin merged 13 commits into
mainfrom
ci/test-coverage-meta-gate

Conversation

@vansin

@vansin vansin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

ci: 元门 —— 新增的测试文件不能落在所有聚合门的扫描范围之外

今天手工扫出三处「有测试、但没有任何 CI job 会跑它」:
server/src 69 个,CI 只点名跑 6 个
agent-network/src 46 个,0 个被引用
agent-network/tests 19 + agent-node/tests 6,两个门自称 complete 却漏了

三处都补掉了(#791/#798/#800),但补完剩下的问题是:
下一个新增的测试文件会不会又静默漏掉。 靠人再扫一遍不是答案。

判据:每个 *.test.ts 要么落在某个聚合门的扫描根下,要么落在 tests/<套件>/ 下
(套件自带,单独计数并逐个列出);两者都不是就红。

两条防空转:

  1. 根必须真的是门的扫描范围 —— 门被删/改名/缩范围就红;
  2. 分母为零退出 3,而不是「没有违规,通过」。

基线:236 个测试文件,231 在门内,5 个套件自带,0 漏网。

三条 mutation 全部转红。其中两条曾经活下来,值得记:

  • B(改掉 test798 的 find 路径)第一版活下来,因为判据是子串
    root not in text,而 'server/src' 也出现在注释和 FAIL 文案里 ——
    宽容的断言把不合规当合规收下了;
  • C(把 test725 的 bun test src/ 收窄成单个文件)第二版活下来,因为正则
    bun test\s+src/ 没有结尾锚定,src/cli.test.ts 也匹配得上 ——
    范围收窄到一个文件,门却仍宣称覆盖整个 src/。
    两条都加了精确判据后转红,过程写进 declares_scope() 的注释和证据文件。

依赖 #798(test798-server-unit-ci)和 #800(test725/745 覆盖 tests/),
本分支叠在两者之上。

@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: 23b57af346

ℹ️ 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 on lines +115 to +116
if f.startswith(root + "/"):
by_root[root] += 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Make coverage classification honor shallow scans

When a test is added under agent-network/tests/<subdir>/ or agent-node/tests/<subdir>/, this prefix check marks it covered, but both newly extended unit runners scan those roots with find ... -maxdepth 1; their discovered/executed counters therefore omit the nested file and both CI gates remain green. Either scan these directories recursively or make the meta-gate reject paths below the depth actually covered.

AGENTS.md reference: AGENTS.md:L7-L7

Useful? React with 👍 / 👎.

Comment thread tests/test798-server-unit-ci/Dockerfile Outdated
Comment on lines +21 to +22
COPY server/package.json ./server/
RUN cd server && npm install

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Install server dependencies from a committed lockfile

This new required CI job copies only server/package.json and runs npm install, while the repository contains no server lockfile and the manifest uses caret ranges. Consequently each fresh Docker build can resolve a different dependency graph, so an upstream compatible-version publication can make the server gate fail—or change tested behavior—without any repository change; commit the lock input and use a frozen/clean install.

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

Useful? React with 👍 / 👎.

Comment on lines +119 to +120
if f.startswith(SUITE_PREFIX):
suite_files.append(f)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Validate suite-owned tests before exempting them

A newly tracked path such as tests/test999-example/new.test.ts is accepted solely because it starts with tests/, even if that directory has no Dockerfile or run.sh, or its runner never invokes the new file. The generic workflows do not discover and execute arbitrary suite Dockerfiles, so this check reports zero orphans while the test is never run; verify the suite's required Docker harness and its coverage of the file before adding it to suite_files.

AGENTS.md reference: AGENTS.md:L9-L9

Useful? React with 👍 / 👎.

Comment on lines +17 to +20
- 'tests/test725-agent-node-unit-ci/**'
- 'tests/test745-agent-network-unit-ci/**'
- 'tests/test798-server-unit-ci/**'
- '.github/scripts/check-test-file-coverage.py'

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Verify that declared gates remain connected to CI

If .github/workflows/qa.yml removes or renames one of the three unit jobs, or stops building and running its Dockerfile, this workflow is not triggered because qa.yml is absent from these paths, and the Python checker only validates the standalone run.sh text rather than any workflow reference. The edited QA workflow can therefore pass with its remaining jobs while every file assigned to the disconnected runner is still reported as covered; trigger on qa.yml and validate each declared gate's live workflow linkage.

AGENTS.md reference: AGENTS.md:L16-L16

Useful? React with 👍 / 👎.

Comment thread .github/workflows/qa.yml
- 'tests/test725-agent-node-unit-ci/**'
- 'tests/test745-agent-network-unit-ci/**'
- 'tests/test746-setup-bun-pin/**'
- 'tests/test798-server-unit-ci/**'

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Trigger server-unit when its race worker changes

The new server image copies tests/test601-hub-scheduled-tasks/race-worker.ts, and server/src/scheduled-tasks-http.test.ts executes that file for its real two-process claim test, but the QA workflow paths include only tests/test798-server-unit-ci/** and not tests/test601-hub-scheduled-tasks/**. A PR that breaks or changes the race worker therefore does not run the server-unit job that consumes it, leaving this portion of the advertised complete server domain unverified; add the copied helper directory to both path filters.

AGENTS.md reference: AGENTS.md:L16-L16

Useful? React with 👍 / 👎.

Comment on lines +14 to +17
[[ "$SOURCE_COMMIT" =~ ^[0-9a-f]{40}$ ]] || {
echo "FAIL: SOURCE_COMMIT must be one full lowercase Git SHA" >&2
exit 1
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Bind reported source commits to the tested tree

This check accepts any syntactically valid SHA without proving that it identifies the files in the image; the newly committed report-test798-server-unit-ci.txt consequently reports 92d9612..., a revision that predates and does not contain the test798 suite itself. Such evidence cannot be reproduced from its stated revision and can silently misattribute results from a dirty or later tree, so derive the identity from the checkout or verify the supplied commit/tree before printing and preserving the report.

AGENTS.md reference: AGENTS.md:L8-L8

Useful? React with 👍 / 👎.

@vansin

vansin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Dependency gate — DO NOT MERGE this stacked head first

Current #801 diff contains the full unmerged payloads of both dependencies:

At this moment #798 is explicitly DO-NOT-MERGE because its rebuilt head still commits a report naming old base 92d96129 as the source of new evidence. Neither dependency has an independent final verdict recorded here.

Therefore #801's green checks cannot authorize merging all three changes through this stacked PR. Required order:

  1. independently review and land corrected test(ci): 给 server 补上聚合单测门(69 个单测此前 CI 只跑 6 个) #798;
  2. independently review and land test(ci): 让 test725/test745 覆盖 tests/ 目录(两个门自称 complete 却漏了 25 个文件) #800;
  3. rebuild/append ci: 元门 —— 新增测试文件不能落在所有聚合门的扫描范围之外(依赖 #798 #800) #801 on the resulting current main so its diff contains only the meta-gate plus its own report;
  4. rerun its baseline and all named mutations on the new exact source, then review those new coordinates.

Until then: DO NOT MERGE #801, and do not interpret mergeable or green CI as dependency approval. No branch was modified by this comment.

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。
上一版把 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)"。
两个门的抬头都写着 "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。
按独审要求重做 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。
按独审 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。
…in/test/pkg-tests-dir-gate' into ci/test-coverage-meta-gate
独立审(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 在解决的问题,不是本门的判据。
@vansin
vansin force-pushed the ci/test-coverage-meta-gate branch from 23b57af to 3a52e63 Compare August 13, 2026 02:38
独立审(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),接线问题去重后只报一次。
@vansin

vansin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

状态更新:依赖门仍然成立,但坐标是新的

上一条评论(Dependency gate — DO NOT MERGE this stacked head first)的结论没有变:
本 PR 必须在 #798#800 落 main 之后再合,因为它的 diff 里带着那两条的完整 payload。
这里只更新坐标与内容,不是推翻那条门。

当前坐标(冻结中,我不再改动)

source     9626c98e…   (代码提交)
report     c06df3c9…   (report-only 子提交)
base       034f0064    (current main)
stack      已按 #798 / #800 的当时头重建过一次

自检:grep -oE 'source_commit=[0-9a-f]{40}' docs/tests/report-test-file-coverage-meta-gate.txt | sort -u
只有 1 个值,等于上面的 source。

合并前请注意:重建 stack 时会遇到两处冲突(我已实测)

我在本地把 10 条 PR 按依赖顺序真做了一遍合并(不是看 diff 猜):

  • 本 PR 会冲突在两个报告文件:docs/tests/report-pkg-tests-dir-gate.txt
    docs/tests/report-test798-server-unit-ci.txt
    原因:本 PR 是 stack,里面带着 #798 / #800 较旧版本的报告副本,而那两份后来又更新过
    (修假锚点、加绝对下限、断言锚到 ^(fail))。
    解法就是既定方案:等前两条落 main 后重建 stack,这两个文件直接取 main 上的版本。
  • 另外 #803 会冲突在 .github/workflows/qa.yml(两边都往同一段 on.paths 与 jobs 追加),
    两边的行都保留即可,无语义冲突。

其余 8 条按序合入干净。

本 PR 这一版新增了什么(相对上一条评论)

三条 P1 的修复,均双向验证:

  1. 深度感知 —— 原来按路径前缀判覆盖,而两个 unit runner 扫 <pkg>/tests 用的是 find … -maxdepth 1
    复现:测试放进 agent-network/tests/sub/ → 元门报「0 个漏网」rc=0,而 runner 的 find 命中 0。
    这道门正是为防「有测试没人跑」而造,却在自己身上漏了同一类问题。
    修后:子目录文件 → rc=1 点名;直属文件 → rc=0。
  2. 套件豁免要校验真实性 —— 原来只要路径以 tests/ 开头就放行。
    修后:伪套件 tests/test999-example/(无 Dockerfile/run.sh)→ rc=1;补上两个文件 → rc=0。
  3. qa.yml 进触发路径 —— 它决定那三个聚合门跑不跑,一改本门前提就可能塌。

外加一条同为 P1 的:元门要验「这道门真的被 CI 跑」,而不只是「它存在且声明了范围」。
判据要求 qa.yml同时出现 -f tests/<suite>/Dockerfiledocker run … <该次 build 的 tag>
两条解耦 mutation 各自红在不同原因上:删掉 docker run(build 保留)→「构建了但没有 docker run 它」;
build -f 路径改名 →「没有 build tests/…/Dockerfile」。

报告里现在共 7 条 mutation,全部双向验过,其中 3 条是「先活下来、再被堵上」的 —— 写进报告是为了让复核者知道薄在哪。

待决清单

本 PR 目前零待改点,唯一前置是合并顺序。

@vansin

vansin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

跨 PR 自查:#801 完全包含 #798,两条独立分支在重复同一份内容

#805/#809 那两条 MAJOR 的共同形态(单看 diff 挑不出毛病,要跨越 diff 边界才看得见)自查其余 PR 时发现的。

事实:

文件 #798 #801
.github/workflows/qa.yml
tests/test798-server-unit-ci/Dockerfile
tests/test798-server-unit-ci/run.sh
docs/tests/report-test798-server-unit-ci.txt

实测合并结果(不是推断,在 detached worktree 上真合了一次):

合 #798 → 干净
再合 #801 → CONFLICT: docs/tests/report-test798-server-unit-ci.txt
qa.yml 干净合并,server-unit: 只出现 1 次

我先假设错了一次,记下来

我最初的假设是「两条独立分支各加同一个 YAML key server-unit: → 合并后重复键 → 整个 qa.yml 失效,带崩所有 QA job」。

实测推翻了它 —— git 认出两处新增内容一致,合成一份,server-unit: 只有 1 个。

这个假设听起来很合理,如果直接当成结论报出去,就是一条不存在的高危。判 PR 合并影响必须真合一次,不能读 diff 推断。


真实影响(比假设小,但是真的)

  1. 重复审查:ci: 元门 —— 新增测试文件不能落在所有聚合门的扫描范围之外(依赖 #798 #800) #801 的审查者会重新审一遍 test(ci): 给 server 补上聚合单测门(69 个单测此前 CI 只跑 6 个) #798 的四个文件,或者反过来误以为已经审过;
  2. 必然冲突:两条都合时 report 文件冲突,需要人工解;
  3. 合并顺序有语义:单独合 ci: 元门 —— 新增测试文件不能落在所有聚合门的扫描范围之外(依赖 #798 #800) #801 会把 test(ci): 给 server 补上聚合单测门(69 个单测此前 CI 只跑 6 个) #798 的内容一起带进去,test(ci): 给 server 补上聚合单测门(69 个单测此前 CI 只跑 6 个) #798 就变成空 PR。

建议(冻结中不动)

收口后二选一:把 #801 rebase 到 #798 上并声明成 stack,或者从 #801 里摘掉那 4 个重复文件。现在不动 —— 两条都在复审中,重构会让复审再次落到过期坐标上。

请审查方知悉这层重叠,评估时不必把重复部分算两次。

@vansin

vansin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

六条 P1 逐条对当前 head c06df3c9 复核(审查针对的是 23b57af3,已过期)

# 位置 裁定
check-test-file-coverage.py 浅扫描 已修
同上,suite 有效性 已修
test-file-coverage.yml 接线 已修
test798/Dockerfile 依赖未锁 仍在
test798/run.sh SHA 只验语法 仍在
qa.yml 缺 test601 路径 在本 PR 里仍缺,但见下

已修的三条(证据)

  • check-test-file-coverage.py:107 def scan_depth(gate_text, root) —— 从门自己的文本里解析 -maxdepth N,不再只按前缀判覆盖;
  • :89 def suite_is_real(path) —— 加入 suite_files 前先校验;
  • test-file-coverage.yml:21 已把 .github/workflows/qa.yml 纳入触发路径(注释里直接写了 codex P1),并新增 :60 gate_is_wired(),在 :171 被调用。

② 仍在:npm install 且无 lockfile

tests/test798-server-unit-ci/Dockerfile:21  COPY server/package.json ./server/
                                       :22  RUN cd server && npm install

仓里没有 server lockfile,manifest 用 caret range。同一个 commit 在不同时间构建可以解析出不同依赖图 —— 上游发一个兼容版本就可能让这道门变红或改变被测行为,而仓库一个字节都没动。这条成立。

⑥ 仍在:SHA 只验语法,不验它对应被测树

tests/test798-server-unit-ci/run.sh:13  SOURCE_COMMIT=${TEST798_SOURCE_COMMIT:-}
                                    :14  [[ "$SOURCE_COMMIT" =~ ^[0-9a-f]{40}$ ]] || FAIL

只要是 40 位小写十六进制就通过。审查指出提交进来的 report 里那个 SHA 早于 test798 套件本身,即它标注的版本里根本没有被测的东西。这条成立,而且正是本仓反复栽过的「报告的 source_commit 与实际被测树脱钩」。

⑤ 需要分开说 —— 它牵出一个跨 PR 的合并顺序问题

test601 路径在 #798 里有 4 处,本 PR 和 #803 各 0 处。而本 PR 完整包含 #798 的全部 4 个文件(此前已记录),带的是一份更旧的 qa.yml 副本

我原本担心「两条都合会把 #798 的修复冲掉」。实测不成立(detached worktree 真合了一次):

合 #798 后          test601 = 4
再合 #801 后        test601 = 4      ← 保住了
唯一冲突            docs/tests/report-test798-server-unit-ci.txt

git 三方合并保住了 #798 的新增,因为本 PR 没有改动那个区域。

但有一个真实且更窄的风险:如果只合本 PR、把 #798 当作冗余关掉,那 4 行就永远不会落地。 而"本 PR 完整包含 #798"恰恰会让这个选择显得合理。

合并顺序有语义:先合 #798,或者先把那 4 行同步进本 PR。


冻结中未改分支。②⑥ 与 ⑤ 的处理待复审收口后一并落。

vansin pushed a commit that referenced this pull request Aug 13, 2026
自查发现:上一版新增了 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 pushed a commit that referenced this pull request Aug 13, 2026
① 桩原来对任何非 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

14 条 PR 的合并就绪度与顺序(实测,不是推断)

main 已 8 小时未动、14 条 PR 无一合入。瓶颈在合并授权 —— 那不是我能行使的。但有权者需要的信息我可以先备好:哪些能干净合、哪些必须处理、按什么顺序代价最小。

方法:在 detached worktree 上从 origin/main 真合一遍(不是读 diff 推断),换两种顺序各跑一次。

结果

顺序 干净合入 冲突
A(#798/#800 在前) 12 #801#803
B(#801 在前) 11 #803#798#800

A 更优。 推荐顺序:

812 → 833 → 834 → 807 → 815 → 809 → 810 → 805 → 800 → 798 → 823 → 835
(#801、#803 单独处理)

两条冲突的性质不同

#801 —— 顺序相关,可消除

冲突在 docs/tests/report-pkg-tests-dir-gate.txtreport-test798-server-unit-ci.txt。根因是本 PR 内容上包含 #798#800(我此前在这条 PR 上记录过:#798 的全部 4 个文件都在本 PR 里)。

所以谁后合谁在 report 文件上撞。A 序把 #798/#800 放前,只有本条冲突;B 序反过来,#798#800 两条都冲突 —— 代价翻倍。

顺带印证一件事:#801 包含 #798」这个重叠本身是可以选择代价的。 合并顺序不是随意的,它决定要解几次冲突。

#803 —— 两种顺序都冲突,是真交叠

冲突在 .github/workflows/qa.yml。它与 #798/#801 改的是同一区域,与顺序无关。这条需要人工解,或先决定 qa.yml 的最终形态再让其中一条带上。

还需要知道的一件事(我此前在本 PR 上记过)

test601 路径在 #798 里有 4 处、本 PR 与 #803 各 0 处。如果只合本 PR、把 #798 当冗余关掉,那 4 行永远不会落地。A 序(#798 先合)天然避开这个坑。


以上只是把决策需要的事实测出来,不构成合并建议,也不是授权。谁合、合不合,仍需 Vincent 本人直接指令。

⑥ 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 构建,不只是这道门;而且生成出来的树我无法在这里验证是否
   仍然全绿。这不该由我单方面决定,如实留作待决,不假装已修。
@vansin

vansin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

⑤⑥ 落地(c06df3c9 → 809faac0);② 需所有者决定,如实标注未修

至此我在这批 PR 上「说过我认、却没实施」的九条清完 —— 其中六条已改,三条留作待决/NOT COVERED,都写明了理由。

SOURCE_COMMIT 只验格式,不验字节

原来只有 [[ "$SOURCE_COMMIT" =~ ^[0-9a-f]{40}$ ]]任何 SHA 都能过 —— 而审查指出提交进来的 report 里那个 SHA 早于本套件自身,那份证据无法从它自称的版本复现。

改成与 test823 相同的做法:构建时把 run.sh 在该 commit 下的 git blob 哈希作为 build-arg 传入,容器内就地重算比对

已验算法一致:

脚本内算法      0e48c36d9ef516e60040ace54bc69c16c161e47f
git hash-object 0e48c36d9ef516e60040ace54bc69c16c161e47f

该机制的端到端红/绿在 #835 上证过两次(传错 blob、blob 对但文件被篡改,都 exit 1),这里不重复。

qa.ymltest601 路径

test798 的镜像 COPY 了 test601 的 race-worker.ts,而 server/src/scheduled-tasks-http.test.ts 会执行它做「两个真 Hub 抢同一 occurrence」。只改那个 worker 的 PR 不该跳过这道门。 已在两处 paths 补上。

这 4 行原本只存在于 #798。若只合本 PR、把 #798 当冗余关掉,它们永远不会落地 —— 此前已在本 PR 记录过这个坑,现在两边都有了,顺序风险随之消失。

npm install 无 lockfile —— 我没有改

实测:

server/package.json  依赖 4 个,4 个全用 caret 范围
server/             无 lockfile / 无 shrinkwrap

审查这条成立 —— 同一个 commit 在不同时间构建确实会解析出不同依赖图。

但修法只有一条:提交一份 lockfile。那是仓库级的依赖钉死决策 —— 它影响每一次 server 构建,不只是这道门;而且生成出来的依赖树是否仍全绿,我在这里无法验证。

这不该由我单方面决定。如实留作待决,不假装已修。


九条的最终去向

PR 已改 未改(写明理由)
#810 措辞、软限额定性、6 处漂移 pin
#803 套件顺序、产物导出、两个 Bun digest test679 的 curl | bash(改构建方式需先实跑)
#801 SHA 绑字节、test601 路径 server lockfile(仓库级决策)

六条改完,三条如实留作未修。 前者是我拖着没做,后者是我不该单方面做 —— 这两类此前混在同一句「待收口后落」里,分不清哪条在等我、哪条在等别人。

@vansin

vansin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

更正我对 ② 的定性:它不是「仓库级决策」,我把前提说错了

我上一条把 server lockfile 列为「不该由我单方面决定」,理由是「提交一份 lockfile 是仓库级的依赖钉死决策」。

查了,这个前提不成立。

agent-network/package-lock.json              ✔ 已提交
docs-site/package-lock.json                  ✔ 已提交
prototype/anet-client-app/package-lock.json  ✔ 已提交
agent-node/                                  ✘ 无
server/                                      ✘ 无

.gitignore 里那条 *.lock 匹配不到 package-lock.json —— 所以不是刻意排除。

5 个包里 3 个已经提交 lockfile。 提交它是本仓既有做法,不是一个需要谁来开先例的政策问题。真实情况是 serveragent-node 不一致,不是「本仓不用 lockfile」。

那它现在卡在哪

卡在验证,不是卡在决策:

生成 lockfile 会把 server 的依赖解析从「每次装当下匹配 caret 的最新」变成「锁住的那一份」。生成的那一刻是行为等价的(锁的就是此刻会装到的),但它可能暴露一个此前被"每次都装新的"掩盖着的问题 —— 所以需要跑一次 test798(server 单测域,69 个文件)确认仍全绿。

那是一次较重的镜像构建。代价是明确的,不是未知的。

我要更正的不只是这一条

上一条我写「不该由我单方面决定」——那句话把一个成本问题说成了权限问题。这两者对读者的意义完全不同:

  • 「需要决策」→ 读者会等一个人来拍板;
  • 「需要一次验证跑」→ 读者知道任何有环境的人都能推进。

我把后者写成了前者,而且没查就写了。这和我今天在 test679 上犯的是同一件事(「没实跑过所以不敢改」,而实跑只要两个最小镜像)——连续第四次:给出的不做理由,成本都远低于我的估计

所以这条的准确状态是

未做,原因是需要一次 test798 的完整构建与运行来确认不回归。 不需要谁来批准。

审查 ② 说的成立: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
@vansin

vansin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

server lockfile —— 已落地并验证(7538cf0)

上一条我把这件事标成「仓库级决策」,又改口说卡在验证。现在两条都清了。

改动

  • 新增 server/package-lock.json(1212 行,npm install --package-lock-only 生成,未装 node_modules)
  • tests/test798-server-unit-ci/Dockerfile:npm installnpm ci,并把 lockfile 一起 COPY

锁到的直接依赖:@modelcontextprotocol/sdk 1.30.0 / bun-types 1.3.14 / hono 4.13.1 / zod 4.4.3

验证 —— 带 lockfile 重建后跑完整套件,SOURCE_COMMIT 用的是含本次改动的那个 SHA:

source_commit=7538cf0a6dc0a87ec8fd75265719432d2945e700
test_files=69
executed_files=69 discovered_files=69 failed_files=0
MUTATION_RED registration-password-floor-weakened rc=1
RESULT: PASS        退出码 0

分母 69 没变,witnessed-red 仍红。

顺带查到的一件事(不在本 PR 处理)

push 之后 Dependabot 头一次能看见 server 的依赖图。结果:

这三条对本仓不适用,因为它们分别需要 hono/jsxmemo()hono/proxyhono/language,而在 576 个 tracked .ts/.tsx 里这三个子模块各 0 处、memo( 0 处 —— 实际上整个 tracked 源码里 hono 一个字都没出现(大小写不敏感)。hono 是经 @modelcontextprotocol/sdk → hono ^4.11.4 传递进来的。

已单独开 issue 跟踪,不塞进这个 PR。

@vansin

vansin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

全量合并盘点:19 个以 main 为 base 的 PR,冲突面实测

上一条我只盘了 qa.yml。这条把范围放到全部 open PR,并且先纠正我自己上一条的一个错

🔴 更正:碰 qa.yml 的不是 3 个 PR,是 5 个

上一条我写「四处待改动」,漏了 #798#801。实测两两文件重叠:

#798 × #801   4 个文件   qa.yml + test798 的 Dockerfile/run.sh/报告
#798 × #803   qa.yml
#798 × #843   qa.yml
#798 × #846   qa.yml
#801 × #803   qa.yml
#801 × #843   qa.yml
#801 × #846   qa.yml
#800 × #801   4 个文件   test725/test745 的 run.sh/Dockerfile/报告
#803 × #823   scripts/qa.sh
#803 × #843   qa.yml
#803 × #846   qa.yml
#809 × #810   rest.md 中英两版
#843 × #846   qa.yml

有重叠的 PR 对 = 13

冲突分两类,处理方式完全不同

A 类 —— 机械并集,不需要判断

qa.yml 的两处 paths 清单:五个 PR 都追加在同一个锚点 - 'tests/test746-setup-bun-pin/**' 之后(#798 +5、#801 +5、#803 +4、#843 +4、#846 +3)。解法是全部保留,顺序无关(paths 是集合)。

job 主体:#798/#801 插在第 59 行、#803 插在第 76 行、#843/#846 追加在末尾。#843×#846 都在末尾外,其余互不重叠。

B 类 —— 真冲突,需要人判断

#803 × #823   scripts/qa.sh
   #803 的 hunk: @@ -74  @@ -143,15
   #823 的 hunk: @@ -138,6 @@ -158,7
   → 143-157 与 138-159 重叠。两者都在改 L1 的执行段,得手工合。

#809 × #810   docs-site/docs/api/rest.md(中英两版同理)
   #809 的 hunk: @@ -34,6  @@ -44,7
   #810 的 hunk: @@ -49,7  @@ -393 @@ -1539 @@ -2181
   → 44-50 与 49-55 重叠。两者都在改同一段 license/trial 说明。

🔴 #801#798/#800 是 diverged,不是包含

#801 的标题写着「依赖 #798 #800」,我原以为它已经含了那两个的提交。实测:

compare(#798 head 38a55ef1 … #801 head)  status = diverged
compare(#800 head 539a7e72 … #801 head)  status = diverged

三者各自带着同一份改动的不同副本。 所以不是「合了 #798 再合 #801 就没事」——它们在 test798/test725/test745 的 Dockerfile、run.sh、报告上会正面冲突,而那几个文件的内容可能已经各自演进过。

这是这次盘点里最值得先处理的一条:在合任何一个之前,先确认这三个 PR 的那 8 个重叠文件谁是最新的

建议顺序

1. #798                     (server 聚合门,基础)
2. #800                     (test725/745 覆盖 tests/;与 #798 无重叠)
3. #801                     ⚠ 先解决与 #798/#800 的 diverged 副本问题
4. #803                     (qa.yml paths 并集 + 主体插在 76 行)
5. #823 → #835              ⚠ #823 与 #803 在 qa.sh 上真冲突,先合的那个赢,后合的要手工合
6. #843 → #844 → #845       (链;paths 并集;合完跑 --write-baseline,见 #843)
7. #846                     (paths 并集 + 末尾 job)
8. #809 → #810              ⚠ rest.md 真冲突,建议同一个人连着合
9. 其余无重叠的:#805 #807 #808 #812 #815 #833 #834 #837 #841 #842

第 9 组那 10 个与任何其他 PR 零文件重叠,可以任意顺序合,不需要协调。

我没做的

vansin pushed a commit that referenced this pull request Aug 13, 2026
上一轮我给出的是「冲突了怎么解」。这一轮做的是让冲突不发生。

冲突源于所有人都追加在同一处:
  paths  五个 PR 都插在 - 'tests/test746-setup-bun-pin/**' 之后
  job    #843 与本 PR 都追加在文件末尾(#803 插在 qa: 之前,#798/#801 插在 59 行)

改动:
  paths  改插到 - 'server/**' 之后 —— 距离 test746 九行,超出 git 默认上下文窗口
  job    从文件末尾挪到 jobs: 之后(这个位置没有别的 PR 用)

paths 是集合、jobs 是映射,位置变化不改变行为。结构断言(每个 job 有 runs-on
与非空 steps)已跑过。

这样合并时不需要任何人去解那个「公共上下文属于双方」的冲突 —— 那个坑我在
#803 上写清楚了,但最好的处理是不让人踩到它。

vansin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

独立交叉深审结论:BLOCKER / DO-NOT-MERGE(当前 exact head 7538cf0a…)。

两条独立实证:

  1. workflow 构建 test798 时没有传 Dockerfile/run.sh 新增的必需 RUNSH_BLOB,GitHub 日志和本地 exact-head 都稳定报 FAIL: TEST798_RUNSH_BLOB 缺失或格式不对
  2. 它携带的 test798 副本相对 test(ci): 给 server 补上聚合单测门(69 个单测此前 CI 只跑 6 个) #798 当前 head 回退了保护:SERVER_TEST_FLOOR=60 退成仅 >0;精确 ^(fail) 断言退成只 grep 测试名。

所以后续 rebase 不能简单选 #801 一侧。请严格按 #800#854#801,并在 #798 落地后手工保留其最新 floor/精确 mutation 断言,补 RUNSH_BLOB 后重跑。

只读审查;未改代码、未 approve/merge/deploy。

@vansin

vansin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

CI 红的根因(只读诊断,没改任何东西)

失败 check:server unit (Docker, non-root),步骤 Run complete server unit domain
日志里唯一的失败行:

FAIL: TEST798_RUNSH_BLOB 缺失或格式不对 —— 无法把 SOURCE_COMMIT 绑到被测字节
##[error]Process completed with exit code 1.

链条断在最后一环

这个 PR 把 blob 绑定加进了 两处:

tests/test798-server-unit-ci/run.sh:25-27
  RUNSH_BLOB=${TEST798_RUNSH_BLOB:-}
  [[ "$RUNSH_BLOB" =~ ^[0-9a-f]{40}$ ]] || { echo "FAIL: …"; exit 1; }

tests/test798-server-unit-ci/Dockerfile:41,45
  ARG RUNSH_BLOB
  ENV TEST798_RUNSH_BLOB=$RUNSH_BLOB

workflow 从来没有供给它.github/workflows/qa.yml:79-84(本 PR 分支上):

- name: Build exact server unit image
  run: |
    docker build \
      --build-arg SOURCE_COMMIT="$GITHUB_SHA" \
      -t anet-test798-server-unit \
      -f tests/test798-server-unit-ci/Dockerfile .

只有 SOURCE_COMMIT,没有 RUNSH_BLOB。于是 ARG RUNSH_BLOB 取到空值,
ENV TEST798_RUNSH_BLOB="",run.sh 的正则校验不过,fail-closed。

门本身是对的 —— 它正确地拒绝了一次「说不清自己测了哪份字节」的运行。
缺的是把值传进去那一行。

对照 #798(它是绿的)

#798 的 run.sh 里 RUNSH_BLOB 命中数:0      ← 它根本没有这条要求
#798 的 Dockerfile:只有 ARG SOURCE_COMMIT

所以这不是 #798 的回归,是本 PR 新加的要求没接完线。

需要补的那一行

run.sh:31 算的是 git 的 blob 哈希:

_actual=$( { printf 'blob %d\0' "$(wc -c < "$_self")"; cat "$_self"; } | sha1sum | cut -d' ' -f1 )

所以 CI 里可以直接用 git rev-parse 拿到同一个值:

      - name: Build exact server unit image
        run: |
          docker build \
            --build-arg SOURCE_COMMIT="$GITHUB_SHA" \
            --build-arg RUNSH_BLOB="$(git rev-parse HEAD:tests/test798-server-unit-ci/run.sh)" \
            -t anet-test798-server-unit \
            -f tests/test798-server-unit-ci/Dockerfile .

我在本地核过这个等价关系:本 PR 分支上
git rev-parse <head>:tests/test798-server-unit-ci/run.sh = 0e48c36d9ef516e60040ace54bc69c16c161e47f,
与 run.sh 自己算法算出的应当一致(两者都是 sha1("blob <len>\0" + 内容))。

🔴 顺带修正我在 #856 里的一句话

我在 #856 给出的合并路线里说过「qa.yml 簇 #798#801#803#843#846 可全部落地」。
那句话验的是能不能合(merge-tree / 真合 + 结构自检),没有验 CI 是否绿
现在实测:#801#803 的 CI 都是红的。合得进去 ≠ 合进去是绿的。
已在 #856 补更正。

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 —— 那件事不在本提交范围内。
vansin pushed a commit that referenced this pull request Aug 13, 2026
本 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」。
七处冲突。逐个核过之后的处理:

## 四个共享文件:取 main,因为 main 那版更严

  tests/test725-agent-node-unit-ci/run.sh
  tests/test745-agent-network-unit-ci/run.sh
  tests/test798-server-unit-ci/run.sh
  tests/test798-server-unit-ci/Dockerfile

🔴 本分支带的是这些文件的**更早一版**,和 main 相比少两样东西:

  - `SERVER_TEST_FLOOR=70`  ← 本分支是 `[[ "$test_files" -gt 0 ]]`,
    也就是删到只剩 1 个测试文件也照样绿
  - `grep -Eq '^\(fail\).*<名字>'` ← 本分支是 `grep -Fq '<名字>'`,
    那条用例**通过**时也会命中,断言只证明了它存在

diff 逐行比过,本分支在这四个文件里**唯一多出来**的是 test725 那条宽容版 grep ——
也就是说取 main 不丢任何东西,取本分支会静默删掉两道判据。

## 本 PR 独有的三样,原样保留并补齐另一半

1. `.github/scripts/check-test-file-coverage.py` + 对应 workflow —— 元门:
   新增测试文件不能落在所有聚合门的扫描范围之外。本地跑过:
   `tracked_test_files=247 / 244 在范围内 / 3 个套件自带 / 0 个漏网`

2. `server/package-lock.json` + Dockerfile 改 `npm ci`。
   注意上一条冲突里我取了 main 的 Dockerfile(它是 `npm install`),
   所以这里**重新贴回** `npm ci` —— 只取一边会把这半个功能丢掉。
   验过 `npm ci` 接受「main 的 package.json + 本 PR 的 lockfile」这一对:
   `added 96 packages in 2s`,rc=0。

3. `RUNSH_BLOB` —— 把报告里的 SOURCE_COMMIT 绑到镜像里被测的字节
   (只验 40 位十六进制的格式是不够的)。同理它有三块:qa.yml 的 build-arg、
   Dockerfile 的 ARG/ENV、run.sh 里的重算比对。**冲突只暴露了第一块**;
   只留 build-arg 会得到一个「传了参数但没人验」的半截门,所以另外两块手工贴回。

## qa.yml 的 paths

main 侧是本分支的**超集**(多了 test224/test597/test679/tests/lib —— #803 注册的
三个套件),取 main。20 条,零重复。

## 合并后跑过

  yaml.safe_load                          OK,5 个 job 名两两不同
  check-l1-paths-sync.py                  rc=0(17 个 L1 套件 / 20 条 path)
  check-qa-trigger-coverage.py            rc=0
  check-test-file-coverage.py             OK,0 个漏网
  bash -n tests/test798-server-unit-ci/run.sh   OK
  npm ci(main 的 package.json + 本 PR 的 lockfile) rc=0

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

vansin commented Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

合了。CI 全绿,元门本地实跑 247 个测试文件 / 244 在聚合门范围内 / 3 个套件自带 / 0 个漏网

🔴 合并时最需要说清楚的一件事

本分支带的是四个共享文件的更早一版,和今天的 main 比,少两道判据:

本分支 main
分母下限 [[ "$test_files" -gt 0 ]] —— 删到只剩 1 个测试也绿 SERVER_TEST_FLOOR=70
变异断言 grep -Fq '<名字>' —— 那条用例通过时也命中 grep -Eq '^\(fail\).*<名字>'

取任何一边都会静默删掉东西。 我逐行 diff 了这四个文件,本分支唯一多出来的就是那条宽容 grep —— 所以取 main 不丢任何东西,取本分支会丢两道判据。

⚠️ 这不是作者的问题:分支是 8-13 切的,那两道判据是之后才进 main 的。但它说明一件事:一个开了几天的 PR,它「带着的」不只是它改的东西,还有它当时那份世界的快照。

本 PR 独有的三样,原样保留 —— 其中两样冲突只暴露了一半

① 元门 check-test-file-coverage.py —— 新增测试文件不能落在所有聚合门的扫描范围之外。这是唯一能防「写了测试但没人跑」的东西,而那正是今晚反复撞到的形状。

RUNSH_BLOB —— 把报告里的 SOURCE_COMMIT 绑到镜像里被测的字节(只验 40 位十六进制的格式是不够的:任何合法 SHA 都能通过,哪怕它根本不含这个文件)。

🔴 它有三块:qa.yml 的 build-arg、DockerfileARG/ENVrun.sh 里的重算比对。冲突只暴露了第一块 —— 因为另两块所在的文件我取了 main。只留 build-arg 会得到一个「传了参数但没人验」的半截门。另两块手工贴回。

npm ci + server/package-lock.json —— 同理,Dockerfile 取了 main(是 npm install),所以 npm ci 要重新贴回去。

合并前先验了这一对能不能装(隔离目录,--package-lock-only 之外没碰任何东西):

main 的 server/package.json + 本 PR 的 lockfile
$ npm ci → added 96 packages in 2s   rc=0

CI 里跑出来是同一个数:#11 [6/12] RUN cd server && npm ci → added 96 packages

关于 RUNSH_BLOB 到底跑没跑

它通过时不打印任何东西,所以日志里看不到它。但它在 run.sh 的第 25–35 行,在所有输出之前:blob 不匹配或环境变量缺失都会 exit 1

日志里 test_files=72 打出来了 ⇒ 那 11 行必然全过。 这是从输出顺序推出来的,不是「绿了所以过了」。

顺带:这个 lockfile 把生产依赖的一件事也做完了

hono 的生产消费方是 server(dependencies),而 main 上没有 server/package-lock.json —— 每次装浮到当下最新的 4.x,不可复现#842 修的是 agent-network 那份(锁文件里 "dev": true)。

本 PR 的 lockfile:hono 4.13.1 prod / @hono/node-server 2.1.0 prod,整份 npm audit --package-lock-only = total 0两个 PR 合起来才把这件事做完。

@vansin
vansin merged commit ba02073 into main Aug 17, 2026
15 checks passed
vansin added a commit that referenced this pull request Aug 18, 2026
* 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/test235-.../socket-harness.ts unconditionally.
     → ENOENT inside tests/test745-agent-network-unit-ci, whose image copies
       only agent-network/ (plus agent-node/package.json and its own run.sh).
  2. Skip when `tests/` is absent.
     → the container HAS a `tests/` directory: test745's own run.sh lives in it.
       The probe answered "full checkout", the assertion ran, and it failed.

The second is the first mistake repeated: probing an incidental feature ("is
there a tests/ directory") instead of the thing itself ("is THIS harness here").
A third detector would be a third guess, so this suite now asserts only what
lives inside its own package — the constant's contents, that reading it does not
boot a server, and that node-server.ts consumes it instead of redeclaring it.

The gap is written down rather than papered over: nothing gates the fact that
socket-harness.ts derives its expectation from OUTBOUND_TOOL_NAMES. That is not
new here — no workflow and neither of qa.sh's L0/L1 lists runs test235 at all,
which is exactly why its assertion could sit wrong on main for as long as it
did. Wiring test235 into CI fixes that and is a separate change: it needs a real
hub and a socket harness, not a unit runner.

Verified in both shapes: full checkout 3 pass / 0 skip; a simulated
package-only image that also contains a partial tests/ (the case that broke
attempt 2) 3 pass / 0 skip.

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(install.sh): stop blaming the registry for every failure (#868) (#908)

The public installer ran

    npm install -g @sleep2agi/agent-network >/dev/null 2>&1 || {
      say "Default registry failed, retrying via npmmirror..."
      ...
    }

Two problems in two lines. The first attempt's stderr went to /dev/null, and
whatever went wrong was then announced as a registry problem. A permission
error, a full disk, an unsupported Node — all told the reader to blame the
registry, and the npmmirror retry then failed the same way a moment later. The
reader ends up with a confident, wrong story and no sign of the real one.

Now the output is kept, and "registry" is only claimed when the output actually
looks like a fetch problem (ETIMEDOUT / ENOTFOUND / ECONNRESET / ECONNREFUSED /
EAI_AGAIN / network / registry / fetch failed / socket hang up). Anything else
is printed verbatim with an explicit note that retrying a different registry
would fail the same way. If the mirror path is taken and also fails, the first
attempt's output is shown too — otherwise the mirror's error replaces the
original one and the actual cause is gone.

Verified against the real script with a stubbed npm, both directions:

  EACCES     → "does not look like a registry problem", no mirror retry,
               EACCES shown to the reader
  ETIMEDOUT  → "looks unreachable, retrying via npmmirror", and on the second
               failure "The mirror failed too" plus the first output

Same stub against main's copy prints "Default registry failed" for the EACCES
case and never shows the word EACCES at all.

(While building the stub I first wrote a fake `node` that did not implement
`-p`, so the script bailed at its version check with "Node.js >= 22.13 required
(current: v22.13.0)" — a self-contradiction that was the harness talking, not
the script. Worth noting because that message is exactly what a reader would
report as a product bug.)

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

* fix(ci): 把唯一的第三方 action 钉到 SHA,并加一道门防下一个 (#746) (#907)

* fix(ci): pin the one third-party action to a SHA, and guard against the next one (#746)

`uses: oven-sh/setup-bun@v2` runs whatever that tag points at today, and the tag
is writable by the action's owner. What it resolves to executes here with this
repository's checkout and secrets in scope, and it can change without any commit
in this repo for anyone to review. Three workflows used it: qa, e2e-docker,
release. All three now pin
0c5077e51419868618aeaa5fe8019c62421857d6, with `# v2` kept as a comment so the
pin still reads as something.

The SHA was resolved from the GitHub API rather than copied out of a log line —
`git/ref/tags/v2` returns that commit directly (a lightweight tag), and it
matches the ref that appeared in tonight's failed download.

#799 already pinned `bun-version: 1.3.14`. That pins the TOOL; …
vansin added a commit that referenced this pull request Aug 18, 2026
…re) (#841)

* ci(test725): agent-node 依赖钉死 —— 提交 lockfile 并改用 npm ci

test725 的 Dockerfile 里,agent-network 用 npm ci(它有 lockfile),而紧挨着的
agent-node 用 npm install —— 同一条 RUN 里两种语义。install 按 caret 解析
"当下最新的兼容版本",意味着同一个 commit 在不同时间构建出不同依赖图:上游发
一个兼容版本就能让这道门变红、或改变被测行为,而仓库一个字节都没动。
agent-node 三个依赖全是 caret(claude-agent-sdk ^0.3.226 / undici ^6.27.0 /
zod ^4.4.3)。这是 #801 给 server 做过的同一件事。

🔴 但这次比 server 那次多一步,请审查者重点看这一步:

agent-node/.gitignore 第 4 行显式写着 package-lock.json。git add 因此拒收,
我第一次提交只带上了 Dockerfile —— 一个引用着仓里不存在的文件的提交,而提交
信息还写着"新增 lockfile"。那个提交已撤(未推出去)。

我没有用 git add -f 绕过去。查了这条规则的来历与横向对照:

  包                          .gitignore 挡 package-lock.json   lockfile 已提交
  agent-node                  有                                 否
  server                      无                                 否(#801 在补)
  agent-network               无                                 是
  docs-site                   无                                 是
  prototype/anet-client-app   无                                 是

agent-node 是 5 个里唯一挡它的。那一行来自初始发布提交 21bc690(2026-05-10)
的模板化 dependencies 块 —— 而 agent-network 的同一个块只挡 bun.lock。
所以它看起来是模板不一致,不是有论证的决定。

但它毕竟是签进仓的规则,我改了它就该说清楚:本次删掉 agent-node/.gitignore
里的 package-lock.json 一行,并在原处留注释说明理由。如果当初那行是有意为之
而我没找到依据,请直接驳回这个 PR —— 撤销它只需要还原一行。

本次改动:
- 删 agent-node/.gitignore 的 package-lock.json 一行(留注释)
- npm install --package-lock-only --include=optional 生成
  agent-node/package-lock.json(1665 行 / 121 个包 / 0 vulnerabilities,
  lockfileVersion 3,与已提交的 agent-network lockfile 一致)
  锁到:@anthropic-ai/claude-agent-sdk 0.3.231 / undici 6.28.0 / zod 4.4.3
  --include=optional 不可省:18 个包带 os/cpu 标记(SDK 与 @openai/codex 的
  各平台二进制),lockfile 覆盖全部平台而不只是生成机那个
- Dockerfile:COPY 带上 package-lock.json,npm install → npm ci --include=optional

* docs(tests): 刷新 test725 报告 —— 记录 npm ci 路径的那次跑

审查(#841)指出:这个 PR 换掉了 test725 的依赖装法(npm install → npm ci),
改变了被测的依赖图,而 docs/tests/report-test725-agent-node-unit-ci.txt 仍记着
npm install 那版镜像的 SOURCE_COMMIT c01e205 —— 仓里没有新路径的留存证据。
指控成立。我在 test812 上做对了这件事(先建套件再留报告),这里漏了检查
「这个套件是不是已经有一份被跟踪的报告」。

新增一节,记录 SOURCE_COMMIT=16e48795 那次:
  image id sha256:eede6a7e…
  镜像内读回 TEST725_SOURCE_COMMIT=16e48795…(比对过,不是参数复述)
  镜像内实装 @anthropic-ai/claude-agent-sdk = 0.3.231,与 lockfile 锁的一致
  1281 pass / 0 fail / Ran 1281 tests across 91 files
  MUTATION_RED readable-attachment-runtime-disconnected rc=1
  RESULT: PASS 退出码 0

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

建门那次的记录整段保留为附录,没有覆盖掉 —— 它记的是这道门当初为什么长成
这样,不是过期垃圾。

---------

Co-authored-by: vansin <smartflowaiteam@gmail.com>
Co-authored-by: t <t@x>
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: 留一份「陈旧 issue 怎么复核」的做法

直接原因:仓里 79 个 open issue,30 个超过 30 天没动,而这 30 个没有一个被任何
open PR 引用。我手工核了其中四条(#175 / #166 / #114 / #177),四条各花十几分钟,
方法没留下来 —— 剩下 26 条又得从头想一遍。

这份不是流程规范,是那四次的做法加踩到的坑:

- 「陈旧」本身不是判据。做完没关 / 做了一半 / 前提不成立 / 真没排到,这四种在
  issue 列表里长得一模一样,时间和 label 区分不了。所以不能批量关 —— 批量关会
  把「做了一半」和「走不通」一起埋掉,而那两种最值得写清楚。
- 先读正文再搜代码。#114 标题像从零开始,正文只有两句;#166 标题说一件事,
  正文实际列了四件 —— 只按标题搜会把「四件里做了三件」判成做完了。
- 对 origin/main 取证,不是对本地工作树。我有一次在老分支上 grep server/src,
  那儿 20 个 .ts 而 main 上是 106,结论建在了另一份代码上。
- 计数只是候选。#114 grep 命中 5 个文件、#177 命中 38 个,看着都像做了;实际
  #114 那 5 个全是日志与测试(数据采到就丢),#177 那 38 个没有一个是实现。
  反例:db.ts 里 grep cost 有 6 处,全是 scrypt KDF 的 cost 参数,与钱无关。
- 找「它要退役的东西还在不在」——比「新东西做了没」更快更硬。#177 要让 #176 的
  capture-pane workaround 退役,而那个 workaround 和 dev-channel flag 都还在。
- 检查正文里的「待确认前提」。#177 写着需先确认 Claude Code 的 managed-settings
  对自定义 plugin 是否可用,而这个确认至今没结论 —— 那它可能是走不通,不是没排到。
- 判不了就写明判不了,不给软结论。
- 🔴 不替 owner 关别人的 issue。复核者拿到的证据往往只覆盖标题那一句,用窄证据
  关一个宽承诺是不对的。

文末附四次复核的结论与各自的决定性证据,可作样例。文档里引用的每处行号与文件
在提交前逐条对 origin/main 复验过(11 项,全部命中)。

* docs(stale-issue-review): 补第 6 步 —— 查代码里有没有指向该 issue 的注释

又核了三条(#332 / #195 / #207),发现一个共同点值得写进方法:很多陈旧 issue
不是被遗忘的,代码里留着指针,只是没人回来更新 issue。

对 30 条跑了一遍按编号 grep:6 条被源码引用(#31/#166/#182/#191/#246/#338)。

但 6 是下界 —— #332#207 也被代码引用却抓不到,因为注释是描述式的、不带编号:
  feishu-tool-deny.ts:250   … a bubblewrap sandbox follow-up tracks the …
  cli.ts:3450               … Cross-machine artifact distribution is a P2 follow-up.

所以这一步要两样都做:按编号 grep + 读正在核的那块代码的注释。只做前者会漏掉
「代码知道、但没写号」的那些,而那些恰恰最该保留 —— 它们证明 issue 还活着。

样例表补三条,其中 #207 的结论形态变了:它开的时候是「跨机分发没人做」,现在是
「通用跨机附件通道(#222)已完成并有 e2e 套件钉着,缺的是把 grok 的 video
artifact 接进去」——而接之前要先评估一个可见性变化(/api/files/<id> 是
any-valid-token、不按 network 隔离,把 0600 的 session-private mp4 传上去会扩大
可见范围)。这类「缺口性质变了」的结论,比「还没做」有用得多。

* docs(stale-issue-review): 修表格断裂与计数

上一次提交把新增的三行贴在了表格之后、中间隔了一个空行 —— markdown 里那会
把一张表切成两张,第二张没有表头。是自查表格结构时发现的(表头 1 个但数据行
分在两处)。

同时把「四次复核」改成七次,开头的「剩下 26 条」改成 23 条。

* docs(stale-issue-review): 修审查提的五条 —— 其中三条是样例在示范本文警告的错误

#846 的审查提了五条,全部成立。三条是我的样例表自己违反了文档写的规则:

① #175 我标成「已交付」,而结论表把已交付等同于建议关闭 —— 可文档第 2 段刚
   说过证据只覆盖标题那一句。这正是「用窄证据关闭宽承诺」。改成「部分核验」。

② #166 我标成「已交付」,而文档刚警告过「四件事里做了三件会被判成做完了」——
   我列的恰好是三项证据。改成「四项中三项已交付」,并写明第四项的真实状态:
   仓库改不了外部会话的工具面板,现状是把边界写进文档并用测试钉住。

③ #114 我用错了判据。拿「completions/tasks 没有用量列」当决定性证据,但
   RFC-015 设计的是独立的 agent_token_usage 表,根本不改那两张表 —— 也就是说
   即使将来完全按 RFC 实现,我那条证据依然成立,却会把它误判成未交付。
   改成核验 RFC 点名的三个符号:agent_token_usage / usage_event_id /
   token_usage_delta 在全仓各只命中 1 个文件,就是 RFC 自己。结论不变,证据换了,
   而且更硬。
   这条最值得记:判据要对着「做完之后会长什么样」设计,不是对着「我猜它会改
   哪里」。我当时没读 RFC-015 的存储设计就选了判据,而那份 RFC 就在仓里。

④ 计数示例没记范围与 flag。审查在全仓重跑得到 26 和 123,而我写的是 5 和 38。
   已补全命令:git grep -lE '<模式>' origin/main -- 'server/src/*.ts'
   'agent-node/src/*.ts'。并记了 -lE 与 -liE 差一个文件
   (readable-attachment-prompt.ts)—— flag 也算范围。

⑤ 本仓要求所有改动跑 Docker E2E。这份文档没有可执行断言,我没有假装它有:
   新增一节说明现状(每个事实附可手工复验的命令),并写出要变成门的可行形态
   (像 test831 那样扫文档引用的 <文件>:<行号>,核它们在 origin/main 上仍指着
   声称的内容)—— 那是独立改动,不在本 PR 里。

新增一节「这份文档的第一版自己违反了它写的规则」,把①②③原样留在文档里。

* ci(test846): 给这份文档补一道行号断言门 —— 兑现审查第 5 条

#846 的审查提了本仓要求所有改动跑 Docker E2E,而这份文档是纯散文、没有可执行
断言。我当时答的是「可以像 test831 那样把它变成门,但那是独立改动」。这就是那步。

做法:文档里嵌一个 ```doc-claims 清单(路径 :: 行号 :: 该行必须包含的子串),
scripts/check-doc-claims.py 逐条打开核对,行号一漂就红。

🔴 为什么是显式清单而不是从正文正则抽:正文的引用是裸文件名(cli.ts:3450、
db.ts:393),而 cli.ts 在 agent-network/bin 和 agent-node/src 各有一个 ——
正则抽出来不知道该开哪个文件。第一版我想直接从正文抽,试到这里才发现。
代价写进了文档:清单和正文可能各写各的,门检查的是清单。

套件 tests/test846-doc-claims(alpine 按 digest 钉版,--network none):
  L0 分母:抽不到断言就红。0 条全过和压根没抽到,打印出来是同一片绿
  L1 witnessed-red:把清单里某条行号 393→394,必须红在 drifted 上;复原回绿
  L2 witnessed-red:清单清空必须红(分母承重),而不是「0 条全过」;复原回绿
  L3 清单里写 ../../etc/passwd 必须红在 path-escapes-repo 上;复原回绿
     (这条是从 check-doc-source-pins.py 那次审查学来的,不是我自己想到的)

验证(容器内):
  claims_checked=8  claims_failed=0
  MUTATION_RED drifted-line-number rc=1
  MUTATION_RED empty-manifest rc=1
  MUTATION_RED path-escapes-repo rc=1
  RESULT: PASS  退出码 0

边界写在三处(脚本头、run.sh 头、文档正文):这道门绿只说明引的行号没漂;
正文的结论对不对、引用之外的散文,它都不检查。别拿它的绿去论证那份文档的
判定是对的。

* docs(tests): 留存 test846 报告

source_commit=c00f5560a490bc39e669149030d157acb8cc6ef9;报告自带 runsh_blob,可用 git rev-parse 独立比对。
按 pre-pr-selfcheck §12,套件是新建的,报告一并落。
RESULT: PASS  exit_code=0

* ci: 把 test846 接进 qa.yml —— 补上我自己漏的那一步

我在建 test831 时写过「没接进 CI 的门只是装饰」,下一轮建 test846 时自己就没接。
是这轮做 qa.yml 协调分析、查各 PR 各改了什么时发现的 —— 不是别人提的。

新增 doc-claims job + 3 条触发路径,形状与 #843 的 doc-source-pins 一致
(同一个锚点后追加、job 附在文件末尾)。

* ci: 把 doc-claims 的插入点挪开,让它与其他四个改 qa.yml 的 PR 不再冲突

上一轮我给出的是「冲突了怎么解」。这一轮做的是让冲突不发生。

冲突源于所有人都追加在同一处:
  paths  五个 PR 都插在 - 'tests/test746-setup-bun-pin/**' 之后
  job    #843 与本 PR 都追加在文件末尾(#803 插在 qa: 之前,#798/#801 插在 59 行)

改动:
  paths  改插到 - 'server/**' 之后 —— 距离 test746 九行,超出 git 默认上下文窗口
  job    从文件末尾挪到 jobs: 之后(这个位置没有别的 PR 用)

paths 是集合、jobs 是映射,位置变化不改变行为。结构断言(每个 job 有 runs-on
与非空 steps)已跑过。

这样合并时不需要任何人去解那个「公共上下文属于双方」的冲突 —— 那个坑我在
#803 上写清楚了,但最好的处理是不让人踩到它。

* 重算 6 条 claim 的行号 —— 这道门在自己的干净树上就红了,而红得对

    FAIL: 干净树上这道门就红了:doc=docs/stale-issue-review.md claims=8
    claims_failed=6
      [drifted] agent-network/bin/cli.ts:5038 不含 «dangerously-load-development-channels»
      [drifted] agent-node/src/cli.ts:3450 不含 «video_gen»
      … 共 6 条

🔴 **一篇讲「陈旧 issue 怎么复核」的文档,自己的引用陈旧了,而且是被它自己带来的门抓到的。**
这不是尴尬,这正是这道门存在的理由 —— 它在合并之前就把作者写下与合并之间那段
时间里发生的漂移暴露了出来。

在合并 main 之后的树上逐条重算(每条都是 grep 那个 needle 拿到的真实行号):

    dangerously-load-development-channels  5038 → 5151   (`claudeArgs.push(...)` 那一行,
                                                          与正文第 74 行「仍在 push(...)」对得上)
    video_gen                              3450 → 3468   (全文件唯一命中)
    Expose CURRENT_TASK_ID                 4273 → 4291   (全文件唯一命中)
    total_cost_usd                         2363 → 2388   (首个命中)
    list_providers                         3867 → 3899   (3897 是注释行,3899 才是注册)
    addNetworkScope                        2979 → 9      (见下)

⚠️ `addNetworkScope` 这条我**没有恢复原意**:它在 `server/src/server.ts` 里有 **24 处**命中,
而正文里没有一行说明当初钉的 2979 指的是哪一处。我选了第 9 行的 `import` ——
它是稳定的、也确实是「server.ts 用了这个符号」的证据,但**它未必是作者当初想指的那处**。
作者若知道原意,请改成那一处。

跑过:
    正常   claims=8  claims_checked=8  claims_failed=0  rc=0
    变异   把一个 needle 改成 `list_providers_gone` → 红,rc=1
    还原   `cmp` 逐字节相同,rc=0

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>
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