From 2566ae705f39c7c179d0058271310949cfbae9ba Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 14 Sep 2026 17:32:06 +0800 Subject: [PATCH 01/10] =?UTF-8?q?AGC=20=E5=A3=B3=20Rust=20=E5=A5=97?= =?UTF-8?q?=E4=BB=B6=E6=94=B9=E4=B8=BA=E3=80=8C=E4=B8=80=E7=89=87=E4=B8=80?= =?UTF-8?q?=E4=B8=AA=20job=E3=80=8D=EF=BC=8C=E5=AE=A2=E6=88=B7=E7=AB=AF=20?= =?UTF-8?q?Rust=20=E5=85=B3=E9=94=AE=E8=B7=AF=E5=BE=84=E5=8E=8B=E5=88=B0?= =?UTF-8?q?=207=20=E5=88=86=E9=92=9F=E4=BB=A5=E5=86=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一轮把 2466 条 bin target 单测切成 4 片放在同一个 job 内多进程并行,run 2102 证明这条路 不通:门禁步骤跑满 18 分钟仍未结束,比整套串行的 507 秒还慢——同一容器内这几片共享 HOME、target 目录与固定临时路径,会互相拖慢。这一轮改成按 job 拆分。 - run-rust-shell-test-shards.mjs 新增 --shard-index=:只跑第 i 片,供 CI 的每个分片 job 使用;覆盖自校验(片并集等于 --list 全集且互斥)仍针对全集执行,所以每个 job 都会 发现分片规则改动,不会被"只跑一片"绕过。负例 --shard-index 超出 --shards 立即失败。 不传 --shard-index 时行为不变(一条命令把 N 片放进程里并行),留给本地全量自测。 - project-ci.yml 由 7 个 job 变 11 个:原 AGC Rust job 拆成 AI game creator shell Rust shard 1/4 ~ 4/4 与 AI game creator shell Rust smoke,加上已有的 Rust crates,AGC 相关 门禁共 6 个 job。4 个片 job 只预热 AGC 壳自己那份锁定依赖,且与 smoke、crates 一样都是 纯 cargo 门禁,因此这 6 个 job 都不再执行 npm ci(每个省 1~3 分钟)。 - check-native-shells.mjs 分组由五个变十个:agc-rust-shard-1 ~ agc-rust-shard-4 与 agc-rust-smoke 取代 agc-rust-shell;每个分片分组的命令是 `npm run ai-game-creator-shell:check:rust:shell -- --shard-index=`。 - project-ci-workflow.test.ts 相应更新:11 个 job、6 个 job 免 npm ci、10 个分组各被一个 job 恰好调用一次,并新增「每个分片 job 都落到 --shard-index」「4 个 index 各一次」 「分片运行器保留覆盖自校验」等断言。 - 同步运维文档、development-workflow、decision-log、pitfalls 与 gitea-ci-triage 技能到 job 级分片口径,并记录 run 2102 的反面实验,避免以后又改回单 job 内并行。 验证:npx vitest run scripts/project-ci-workflow.test.ts(12 passed)、eslint、prettier、 check:encoding(13330 文件)、check:doc-index、--groups=contract、mobile check-config 全绿; 分片运行器本地以 agent-runtime-core(7 条 → 2/2/2/1)验证 --shard-index 四个 index 各自 只跑一片、片 TMPDIR 隔离、无 index 时全量模式不变。Gitea master 分支保护需补 6 个新 context(共十一个),其中含 4 个分片 job、smoke job 与 crates job。 Co-authored-by: DotCraft <273930855+dotcraft-ai@users.noreply.github.com> --- .gitea/workflows/project-ci.yml | 166 ++++++++++++++++-- .../scripts/run-rust-shell-test-shards.mjs | 67 +++++-- .../shared-memory/decision-log.md | 24 +-- .../shared-memory/development-workflow.md | 2 +- docs/project-memory/shared-memory/pitfalls.md | 23 +-- ...发运维】本地开发验证与生产运维-2026-05-15.md | 13 +- package.json | 6 +- scripts/check-native-shells.mjs | 70 ++++++-- scripts/project-ci-workflow.test.ts | 73 ++++++-- 9 files changed, 354 insertions(+), 90 deletions(-) diff --git a/.gitea/workflows/project-ci.yml b/.gitea/workflows/project-ci.yml index b4e69e78f..f629f6251 100644 --- a/.gitea/workflows/project-ci.yml +++ b/.gitea/workflows/project-ci.yml @@ -31,22 +31,24 @@ env: # 让它在最少的等待下占用并发槽位;其余 job 按时长递减排列。 # # 客户端(微信壳 / Expo 移动壳 / Tauri 桌面壳 / AI 游戏创作壳)门禁原先全部串在 -# `Native shell tests` 一个 job 里,实测 18 分 37 秒。现在按门禁组拆成三个 job: +# `Native shell tests` 一个 job 里,实测 18 分 37 秒。现在按门禁组拆成一个分组一个 job: # `Native shell tests`(契约 + H5 / 微信 / 移动 / 桌面壳门禁 + 发布构建 smoke)、 -# `AI game creator shell web tests`(typecheck + 壳内测试)与 -# `AI game creator shell Rust tests`(AGC 壳 bin 单测分片并行 + agent-run smoke), -# 再把 AGC 壳依赖的共享 / 平台 crate 测试拆成 `AI game creator shell Rust crates`。 -# 各自的命令与拆分前逐一对应,本地 `npm run check:native-shells` 仍是同一条串行序列。 +# `AI game creator shell web tests`(typecheck + 壳内测试)、`AI game creator shell +# Rust shard 1/4` 到 `4/4`(AGC 壳 bin 单测按名单分 4 片)、`AI game creator shell +# Rust smoke`(agent-run smoke)与 `AI game creator shell Rust crates`(AGC 壳依赖的 +# 共享 / 平台 crate 测试)。各自的命令与拆分前逐一对应,本地 +# `npm run check:native-shells` 仍是同一条串行序列。 # -# AGC 壳的 bin 单测(2466 条)按名单分 4 片、每片一个进程并行执行,片内保持 -# `--test-threads=1`:当年线程并行会互相干扰的是进程内后台锁与异步终态,进程分片 -# 天然隔离,每片另有独立 TMPDIR,因此不必再让整套用例串成一小时级的尾巴。 +# AGC 壳的 bin 单测(2466 条)按名单分 4 片、一片一个 job:片内保持 `--test-threads=1` +# (当年线程并行会互相干扰的是进程内后台锁与异步终态),片与片之间靠 job 级并发摊开。 +# 不要改回「一个 job 里多进程并行这几片」:同一容器内它们会争抢共享 HOME、target 与固定 +# 临时路径,实测比整套串行还慢。每个分片 job 都会自校验「片并集等于全集且互斥」。 jobs: - # 客户端 AGC 壳自身的 Rust 门禁:bin target 单测按名单分 4 片并行(片内仍保持 - # `--test-threads=1`)加 agent-run smoke。这里不装 npm 依赖:壳 Rust 门禁与 smoke - # 只用 cargo 与 node 内建模块,也只需要 AGC 壳自己那份锁定依赖。 - ai-game-creator-shell-rust-tests: - name: AI game creator shell Rust tests + # AGC 壳自身的 Rust bin 单测分片,4 片各自独立 job 并发执行、片内仍保持 + # `--test-threads=1`。这里不装 npm 依赖:壳 Rust 门禁只用 cargo 与 node 内建模块, + # 也只需要 AGC 壳自己那份锁定依赖。 + ai-game-creator-shell-rust-shard-1: + name: AI game creator shell Rust shard 1/4 runs-on: genarrative-ci steps: - name: Checkout full history from Gitea @@ -78,8 +80,142 @@ jobs: sleep $((attempt * 2)) done - - name: Run AI game creator shell Rust gates - run: npm run check:native-shells:agc-rust-shell + - name: Run AI game creator shell Rust shard 1/4 + run: npm run check:native-shells:agc-rust-shard-1 + + ai-game-creator-shell-rust-shard-2: + name: AI game creator shell Rust shard 2/4 + runs-on: genarrative-ci + steps: + - name: Checkout full history from Gitea + env: + GENARRATIVE_GITEA_FETCH_DEPTH: '0' + GENARRATIVE_GITEA_TOKEN: ${{ github.token }} + run: genarrative-gitea-checkout + + - name: Validate preinstalled CI job image and sandbox + run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh + + - name: Prepare AI game creator shell Rust dependencies + shell: bash + run: | + set -euo pipefail + for attempt in $(seq 1 5); do + if cargo fetch --locked \ + --target x86_64-unknown-linux-gnu \ + --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml; then + break + fi + if [[ "${attempt}" -eq 5 ]]; then + echo 'AI game creator shell Cargo dependency fetch failed after 5 attempts.' >&2 + exit 1 + fi + sleep $((attempt * 2)) + done + + - name: Run AI game creator shell Rust shard 2/4 + run: npm run check:native-shells:agc-rust-shard-2 + + ai-game-creator-shell-rust-shard-3: + name: AI game creator shell Rust shard 3/4 + runs-on: genarrative-ci + steps: + - name: Checkout full history from Gitea + env: + GENARRATIVE_GITEA_FETCH_DEPTH: '0' + GENARRATIVE_GITEA_TOKEN: ${{ github.token }} + run: genarrative-gitea-checkout + + - name: Validate preinstalled CI job image and sandbox + run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh + + - name: Prepare AI game creator shell Rust dependencies + shell: bash + run: | + set -euo pipefail + for attempt in $(seq 1 5); do + if cargo fetch --locked \ + --target x86_64-unknown-linux-gnu \ + --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml; then + break + fi + if [[ "${attempt}" -eq 5 ]]; then + echo 'AI game creator shell Cargo dependency fetch failed after 5 attempts.' >&2 + exit 1 + fi + sleep $((attempt * 2)) + done + + - name: Run AI game creator shell Rust shard 3/4 + run: npm run check:native-shells:agc-rust-shard-3 + + ai-game-creator-shell-rust-shard-4: + name: AI game creator shell Rust shard 4/4 + runs-on: genarrative-ci + steps: + - name: Checkout full history from Gitea + env: + GENARRATIVE_GITEA_FETCH_DEPTH: '0' + GENARRATIVE_GITEA_TOKEN: ${{ github.token }} + run: genarrative-gitea-checkout + + - name: Validate preinstalled CI job image and sandbox + run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh + + - name: Prepare AI game creator shell Rust dependencies + shell: bash + run: | + set -euo pipefail + for attempt in $(seq 1 5); do + if cargo fetch --locked \ + --target x86_64-unknown-linux-gnu \ + --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml; then + break + fi + if [[ "${attempt}" -eq 5 ]]; then + echo 'AI game creator shell Cargo dependency fetch failed after 5 attempts.' >&2 + exit 1 + fi + sleep $((attempt * 2)) + done + + - name: Run AI game creator shell Rust shard 4/4 + run: npm run check:native-shells:agc-rust-shard-4 + + # agent-run smoke 会 spawn `cargo run`(走壳自己的 manifest),同样不装 npm 依赖, + # 单独一个 job,免得把已经压到 4 分钟级的片 job 拖长。 + ai-game-creator-shell-rust-smoke: + name: AI game creator shell Rust smoke + runs-on: genarrative-ci + steps: + - name: Checkout full history from Gitea + env: + GENARRATIVE_GITEA_FETCH_DEPTH: '0' + GENARRATIVE_GITEA_TOKEN: ${{ github.token }} + run: genarrative-gitea-checkout + + - name: Validate preinstalled CI job image and sandbox + run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh + + - name: Prepare AI game creator shell Rust dependencies + shell: bash + run: | + set -euo pipefail + for attempt in $(seq 1 5); do + if cargo fetch --locked \ + --target x86_64-unknown-linux-gnu \ + --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml; then + break + fi + if [[ "${attempt}" -eq 5 ]]; then + echo 'AI game creator shell Cargo dependency fetch failed after 5 attempts.' >&2 + exit 1 + fi + sleep $((attempt * 2)) + done + + - name: Run AI game creator shell agent-run smoke + run: npm run check:native-shells:agc-rust-smoke # AGC 壳依赖的共享 / 平台 crate 测试用的是 server-rs workspace 与两个无锁独立 crate # 的 manifest,属另一套依赖图,因此单独一个 job 预热、单独跑。 diff --git a/apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs b/apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs index 7cefb0ed2..9e0ae58bb 100644 --- a/apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs +++ b/apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs @@ -6,18 +6,25 @@ // **同一进程内**互相干扰(见 development-workflow 的 Tauri suite 单线程口径)。代价是 // 整套用例串行跑满 507 秒,占掉 CI 上 `AI game creator shell Rust tests` job 的大头。 // -// 这里保留「片内串行」的既有口径,只把用例集合切成 N 片、让每片在**独立进程**里并行: -// 当年线程并行的两个根因(进程内全局锁、异步终态)在多进程下不存在,每片还会拿到自己的 -// TMPDIR,避免 tempfile 目录互相踩。片并集必须等于全集、且不得重复,数量不符即失败, -// 防止分片规则改动后静默漏跑。 +// 这里保留「片内串行」的既有口径,只把用例集合切成 N 片: +// - CI 用 `--shard-index=` 让**每个 job 只跑一片**,靠多个 job 并发把整套用例摊开; +// - 本地不传 `--shard-index` 时把 N 片放进 N 个**独立进程**并行(--concurrency 可调), +// 保留一条命令跑全量的入口。 +// 片并集必须等于全集、且不得重复,数量不符即失败,防止分片规则改动后静默漏跑;该校验 +// 与「只跑一片」无关,因此在每个 job 上都会执行。 +// +// 注意:同一容器内多进程并行这套用例(共享 HOME、target、固定临时路径)实测会互相拖慢, +// 比串行还慢,所以 CI 走「一个 job 一片」而不是单 job 内并行。 // // 用法: -// node scripts/run-rust-shell-test-shards.mjs [--shards=4] [--concurrency=4] +// node scripts/run-rust-shell-test-shards.mjs --shards=4 --shard-index=2 # CI:只跑第 2 片 +// node scripts/run-rust-shell-test-shards.mjs [--shards=4] [--concurrency=4] # 本地:全量 // node scripts/run-rust-shell-test-shards.mjs --manifest= --target-kind=lib --no-locked // // 参数: // --shards= 分片数,默认 4 -// --concurrency= 同时运行的片数,默认等于分片数 +// --shard-index= 只跑第 i 片(1..shards);不传则跑全部分片 +// --concurrency= 同时运行的片数,默认等于分片数;--shard-index 时恒为 1 // --manifest= Cargo.toml,默认 ../src-tauri/Cargo.toml(相对本脚本) // --target-kind= bin | lib,默认 bin(本地自测小 crate 时用 lib) // --bin= bin target 名,默认 genarrative-ai-game-creator-shell @@ -49,6 +56,7 @@ function parsePositiveInteger(name, rawValue) { const options = { shards: 4, + shardIndex: undefined, concurrency: undefined, manifestPath: path.join(shellRoot, 'src-tauri', 'Cargo.toml'), targetKind: 'bin', @@ -75,6 +83,9 @@ for (const rawArgument of process.argv.slice(2)) { case 'shards': options.shards = parsePositiveInteger('--shards', value); break; + case 'shard-index': + options.shardIndex = parsePositiveInteger('--shard-index', value); + break; case 'concurrency': options.concurrency = parsePositiveInteger('--concurrency', value); break; @@ -105,6 +116,12 @@ if (!fs.existsSync(options.manifestPath)) { fail(`manifest does not exist: ${options.manifestPath}`); } +if (options.shardIndex !== undefined && options.shardIndex > options.shards) { + fail( + `--shard-index (${options.shardIndex}) must be within --shards (${options.shards})`, + ); +} + const concurrency = options.concurrency ?? options.shards; const crateRoot = path.dirname(options.manifestPath); @@ -380,17 +397,35 @@ async function main() { const shards = splitTestNames(testNames, options.shards); assertShardsCoverEveryTest(testNames, shards); - console.log( - `[rust-shards] ${testNames.length} tests, ${shards.length} shard(s), concurrency ${Math.min(concurrency, shards.length)}`, - ); - shards.forEach((shardTestNames, index) => { - console.log( - `[rust-shards] shard ${index + 1}/${shards.length}: ${shardTestNames.length} test(s)`, - ); - }); + const selectedShards = + options.shardIndex === undefined + ? shards.map((shardTestNames, index) => ({ index, shardTestNames })) + : [ + { + index: options.shardIndex - 1, + shardTestNames: shards[options.shardIndex - 1], + }, + ]; - const results = await runWithConcurrency(shards, (shardTestNames, index) => - runShard(executable, index, shards.length, shardTestNames), + if (options.shardIndex === undefined) { + console.log( + `[rust-shards] ${testNames.length} tests, ${shards.length} shard(s), concurrency ${Math.min(concurrency, shards.length)}`, + ); + for (const { index, shardTestNames } of selectedShards) { + console.log( + `[rust-shards] shard ${index + 1}/${shards.length}: ${shardTestNames.length} test(s)`, + ); + } + } else { + console.log( + `[rust-shards] ${testNames.length} tests, ${shards.length} shard(s), running shard ${options.shardIndex}/${shards.length} (${selectedShards[0].shardTestNames.length} test(s))`, + ); + } + + const results = await runWithConcurrency( + selectedShards, + ({ index, shardTestNames }) => + runShard(executable, index, shards.length, shardTestNames), ); let failed = false; diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 93338917b..60d4694c4 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -3,16 +3,17 @@ > 用途:记录已经确认、会影响后续开发的长期技术/产品/协作决策。短期讨论不要写在这里。 > 当前口径:历史条目的旧路径、旧版本和已退役对象只用于追溯,不构成现行实现依据;如与当前代码或 `docs/README.md` 冲突,以当前代码和最新专题文档为准。 -## 2026-09-14 AGC 壳 Rust 套件改为「分片并行 + 片内串行」,客户端 Rust 关键路径压到 7 分钟以内 +## 2026-09-14 AGC 壳 Rust 套件按「一片一 job」拆分,客户端 Rust 关键路径压到 7 分钟以内 -- 背景:`AI game creator shell Rust tests` 是客户端 CI 的关键路径(run 2097 实测 15 分 27 秒)。拆开来看:前置 5 分 30 秒(checkout 10s + `npm ci` 2m45s + Cargo fetch 2m35s)、编译 1m39s、**AGC 壳 bin target 的 2466 条单测串行 507s**、`agent-run` smoke 51s。这 2466 条全在 `apps/ai-game-creator-shell/src-tauri/src/main.rs` 的 bin target 里,一条 `cargo test … -- --test-threads=1` 跑完。 -- 为什么原本整套串行:2026-07-21 的 `a273377b1`(「稳定AI原生壳全量测试」)把 Tauri suite 固定为 `--test-threads=1`,理由是**共享 Agent Runtime 后台锁与异步终态在 libtest 并行调度下互相干扰**——即同进程内的全局锁、异步终态与进程级 static 被交叉触发;当时的口径是「修正 suite 调度口径,不放宽断言」。另有少量用例自身会 spawn `cargo test`,需要独占 cargo 的 package cache / target 锁。 -- 决策:**只把「整套串行」放宽到「片内串行」**。新增 `apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs`:`cargo test --no-run` 编译一次拿到测试可执行文件,用 `--list` 取全部用例名,排序后按 `index % shards` 切 4 片,每片作为**独立进程**执行 ` --exact <名单> --test-threads=1`,并给每片独立 `TMPDIR`。进程分片不共享当年出问题的进程内状态,也不共享 tempfile 目录,因此可以并行;片内仍严格串行,不退回整套并行,也不需要放宽任何断言。 -- 不变量:片并集必须等于 `--list` 的全集且互斥,数量或成员不符立即失败(`assertShardsCoverEveryTest`),防止分片规则改动后静默漏跑门禁。 -- 配套拆分:`npm run ai-game-creator-shell:check:rust` 拆成 `:rust:crates`(`agent-runtime-core`、`agent-runtime-orchestration`、`platform-llm`、`shared-contracts`)与 `:rust:shell`(分片运行器),聚合脚本保持同序,因而 `ai-game-creator-shell:check` 与本地 `npm run check:native-shells` 语义不变。CI 相应新增 `AI game creator shell Rust crates` job(第 7 个),`AI game creator shell Rust tests` 只保留壳分片与 smoke。 -- 前置瘦身:AGC 壳有独立 `Cargo.lock`,其 path 依赖已包含 `platform-llm` / `platform-agent` / `agent-runtime-core` / `shared-contracts`,所以壳 job 只需预热 AGC 壳这一份 manifest;壳 Rust 门禁与 `agent-run` smoke 只用 cargo 与 node 内建模块,因此 **`AI game creator shell Rust tests` 与 `AI game creator shell Rust crates` 都不再执行 `npm ci`**(各省 1~3 分钟)。 -- 影响范围:`.gitea/workflows/project-ci.yml`(七个 job)、`scripts/check-native-shells.mjs`(分组由五个变六个:新增 `agc-rust-crates`、`agc-rust-shell`,移除 `agc-rust`)、根 `package.json`、`scripts/project-ci-workflow.test.ts`(新增纯 cargo job 免 `npm ci` 与分片运行器断言)、开发运维文档与共享记忆。Gitea `master` 分支保护的 required context 是追加式的,需补上 `Project CI / AI game creator shell Rust crates (pull_request)`。 -- 验证方式:`npx vitest run scripts/project-ci-workflow.test.ts`;分片运行器本地以 `agent-runtime-core`(7 条)与 `platform-llm`(146 条)验证分片、`--exact` 与片 TMPDIR 隔离;`node scripts/check-native-shells.mjs --groups=contract` 回归。预期 `AI game creator shell Rust tests` 收敛到 6 分钟左右(前置 1m30s + 编译 1m39s + 分片约 2 分钟 + smoke),整轮 wall clock 由 `Backend tests`(8 分 36 秒)与 runner 并发(4)决定。 +- 背景:`AI game creator shell Rust tests` 是客户端 CI 的关键路径(run 2097 实测 15 分 27 秒)。拆开来看:前置 5 分 30 秒(checkout 10s + `npm ci` 2m45s + Cargo fetch 2m35s)、编译 1m39s、**AGC 壳 bin target 的 2466 条单测串行 507s**、`agent-run` smoke 51s。这 2466 条全在 `apps/ai-game-creator-shell/src-tauri` 的 bin target 里,一条 `cargo test … -- --test-threads=1` 跑完。 +- 为什么原本整套串行:2026-07-21 的 `a273377b1`(「稳定AI原生壳全量测试」)把 Tauri suite 固定为 `--test-threads=1`,理由是**共享 Agent Runtime 后台锁与异步终态在 libtest 并行调度下互相干扰**——即同进程内的全局锁、异步终态与进程级 static 被交叉触发;另有少量用例自身 spawn 当前测试二进制跑 fixture,会碰容器里共享的 target 与固定临时路径。当时的口径是「修正 suite 调度口径,不放宽断言」。 +- 决策:**把 2466 条按名单切成 4 片,一片一个 CI job**(片内仍严格 `--test-threads=1`,不放宽任何断言),片与片之间靠 job 级并发摊开。新增 `apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs`:`cargo test --no-run` 编译一次拿到测试可执行文件,`--list` 取全部用例名,排序后按 `index % shards` 切片;CI 的每个分片 job 用 `--shard-index=` 只跑自己那片(`--exact <名单>` 加独立 `TMPDIR`),本地不传该参数时仍是一条命令把 4 片放进程里并行。 +- 反面实验(run 2102,已废弃,勿重做):起先让**同一个 job** 内的 4 个进程并行跑这 4 片,门禁步骤跑满 18 分钟仍未结束,比整套串行的 507 秒还慢——同一容器内多片共享 `HOME`、target 目录与固定临时路径,会互相拖慢。所以 CI 走 job 级分片,`--shard-index` 是唯一入口。 +- 不变量:片并集必须等于 `--list` 的全集且互斥,数量或成员不符立即失败(`assertShardsCoverEveryTest`);该校验与「只跑一片」无关,因此在每个分片 job 上都会执行,防止分片规则改动后静默漏跑门禁。 +- 配套拆分:`npm run ai-game-creator-shell:check:rust` 拆成 `:rust:crates`(`agent-runtime-core`、`agent-runtime-orchestration`、`platform-llm`、`shared-contracts`)与 `:rust:shell`(分片运行器),聚合脚本保持同序,因而 `ai-game-creator-shell:check` 与本地 `npm run check:native-shells` 语义不变。AGC 相关门禁在 CI 里变成 6 个 job:`AI game creator shell Rust shard 1/4` ~ `4/4`、`AI game creator shell Rust smoke`、`AI game creator shell Rust crates`。 +- 前置瘦身:AGC 壳有独立 `Cargo.lock`,其 path 依赖已包含 `platform-llm` / `platform-agent` / `agent-runtime-core` / `shared-contracts`,所以 4 个分片 job 与 smoke job 只需预热 AGC 壳这一份 manifest;这些 job 只用 cargo 与 node 内建模块,因此 **5 个壳 job 与 crates job 都不再执行 `npm ci`**(每个省 1~3 分钟)。 +- 影响范围:`.gitea/workflows/project-ci.yml`(十一个 job)、`scripts/check-native-shells.mjs`(分组由五个到十个:新增 `agc-rust-crates`、`agc-rust-shard-1..4`、`agc-rust-smoke`,移除 `agc-rust` 与随后的 `agc-rust-shell`)、根 `package.json`、`scripts/project-ci-workflow.test.ts`(新增纯 cargo job 免 `npm ci`、分片运行器覆盖校验、crate 级 job 预热顺序断言)、开发运维文档与共享记忆。Gitea `master` 分支保护的 required context 是追加式的,需补上 6 个新 context(共十一个)。 +- 验证方式:`npx vitest run scripts/project-ci-workflow.test.ts`;分片运行器本地以 `agent-runtime-core`(7 条 → 2/2/2/1)与 `platform-llm`(146 条 → 49/49/48)验证分片、`--exact` 与片 TMPDIR 隔离,负例 `--shard-index=5` 立即失败;`node scripts/check-native-shells.mjs --groups=contract` 回归。预期每个分片 job 收敛到 5 分钟以内(前置约 1 分 30 秒 + 编译约 1 分 39 秒 + 约 617 条用例)。 - 关联文档:[开发运维](../../【开发运维】本地开发验证与生产运维-2026-05-15.md)、[踩坑记录](pitfalls.md)。 ## 2026-09-14 客户端 CI 按门禁组拆成三个 job,AGC 的 web / rust 两段并行 @@ -63,7 +64,7 @@ - 影响范围:`src/view/project-development/ResourceClassificationPanel.tsx`(选择器 + 写回分叉)、`src/view/project-development/index.tsx`(角标取值)、`tests/resourceClassificationPanel.test.tsx`、`tests/projectResourceLiveIntegration.test.tsx`(真宿主跟随链)、`tests/appSurface/project-development.suite.ts`(原媒体类型角标断言改资源类型)、PRD §5.3、AGC 资源工作台 V3 端到端验收用例 S5、[`【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md) 的写入命令一节。Rust / manifest 字段构成 / SpacetimeDB 都不动。 - 验证方式:`npm run test -- apps/ai-game-creator-shell/tests/resourceClassificationPanel.test.tsx apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx apps/ai-game-creator-shell/tests/appSurface.test.ts` + `npm run typecheck` + `npm run check:encoding` + `git diff --check`。变异验证(均已实测):①写入改回"永远回传落盘原值" → 3 条面板用例红;②没碰控件时改回"回传自愈值"(`gameCreationAppAssetCategory`)→ 2 条对照用例红;③选择器改读落盘值 → 选择器用例红;④角标改回 `projectResourceTypeLabel` → 角标用例与 appSurface 断言红。**不可构造**的变异:把角标"只在挂载时算一次"—— 选中资源本身就会把画布切进它所在栏目,改类型又让卡片换到另一个栏目分组,React 每次都卸载重建卡片宿主,挂载快照与实时读数在 DOM 上同形。 -## 2026-09-11 pre-commit 补 Rust 格式守卫:lint-staged 增 *.rs,本地不再只靠 CI 的 check:rustfmt +## 2026-09-11 pre-commit 补 Rust 格式守卫:lint-staged 增 \*.rs,本地不再只靠 CI 的 check:rustfmt - 背景:本 PR 已因 `check:rustfmt` 红过一次(`15660a98b` 修掉本批遗留的 8 处格式偏差)。根因是 `.husky/pre-commit` 只跑 `lint-staged`,而它的 glob 只覆盖 `*.{js,mjs,cjs,ts,tsx}` —— **Rust 格式在本地没有任何守卫**,唯一防线是 CI 那一侧(`check-repository-ci.sh` → `npm run lint` → `check:rustfmt`);本地没人跑得到的门禁等于没有门禁,「本地全绿、CI 才红」就会反复发生。 - 决策:lint-staged 增 `"*.rs": ["node scripts/lint-staged-rustfmt.mjs"]`。`cargo fmt` 只按 workspace 粒度格式化、**不接受文件参数**(lint-staged 会把命中的暂存路径追加到命令末尾),所以用包装脚本忽略 argv,对 `server-rs` 与 `apps/ai-game-creator-shell/src-tauri` 两个 workspace 各跑一次 `cargo fmt --all --manifest-path -- --check`,**只查不改** —— pre-commit 不应该自动改写别人正在改的 Rust 文件。workspace 路径与既有 `check:rustfmt` 一样写成 cwd 相对,因为 lint-staged 以 git 根为 cwd 运行任务。 @@ -8580,6 +8581,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 原因:预览起停不是源码变更,不能靠"给它也推一次 revision"来消除冲突——那会让运行时的验证凭证(`expected_revision` / `verified_revision` / `failed_playtest_revision`)凭空漂移,把自主构建流程拖进无谓的重新验证。正确的修法是把判据面收回到契约所说的对象,而不是让簿记写入承担源码语义。 - 验证:`projectResourceLiveUpdateModel.test.ts` 新增「同 revision 只有 `preview` 变化必须被接受」与「同 revision 资产变化必须仍被拒收」两条;变异回整份 JSON 指纹后前者以 `expected 'revision-conflict' to be 'accepted'` 失败。定向:模型 17 passed、workspaceLauncherManifestMerge 5 passed、appSurface 413 passed、typecheck exit 0、check:encoding 4399 files passed。 - 影响范围:`apps/ai-game-creator-shell/src/view/project-development/projectResourceLiveUpdateModel.ts`、`apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts`、`pitfalls.md` 本条。 + ## 2026-09-11 AGC 资源替换按 PRD 恢复为「版本级替换」:改绑定落在追加的新版本上 - 背景:PRD §3.2(`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md:36-41`)要求「替换版本引用资源时创建下一迭代版本,不原地修改既有版本」,并要求新版本记录 `parentVersionId`、替换前后资源身份与创建原因;§5.3(:356-387)给出 `ProjectVersionResourceReplacement` 与「三项兼容性必须同时为 true 才能创建下一版本」。本文件 2026-09-10 那条(`decision-log.md:8263`)与 Issue #309 的 C1 决策 / 贯穿性决策 6 / 验收总纲当时写的是相反口径:「改 manifest 绑定……**不创建新版本**」「替换功能(候选素材 / 替换关系 / 替换队列 / Agent 审核 / 单点替换)整条取消,不实现」。用户裁决:**按 PRD 来**,实际机制仍是「改 manifest 绑定」。 @@ -8645,13 +8647,13 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 影响范围:`apps/ai-game-creator-shell/src/features/resource-canvas/{resourceCanvasBottomToolbarModel.ts,ResourceCanvasBottomToolbarView.tsx,ResourceCanvasAssetGenerationPanelView.tsx,ResourceCanvasGenerationPanelView.tsx,resourceCanvasChrome.css}`、`apps/ai-game-creator-shell/src/view/project-development/index.tsx`、测试 `apps/ai-game-creator-shell/tests/{resourceCanvasBottomToolbar.test.tsx(新增),resourceCanvasGenerationEntry.test.tsx,projectResourceLiveIntegration.test.tsx,appSurface/project-development.suite.ts}`、PRD §3.10 / §7.9 / §8、`docs/technical/【AGC】栏目画布底部工具栏入口矩阵-2026-09-13.md`(新增)、验收用例 S11 / S11a。**未动**:Rust、external v1 / OpenAPI、`packages/`(只消费共享 chrome)、SpacetimeDB。 - 验证方式:`npx vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts`(423 passed,含新增 6 条:栏目分流与总览不渲染、UI 栏 5 个入口载荷、前置缺失可点击说明且零请求、角色栏 2 个入口、音频入口走既有链路、上传 + 配对读清单,另 1 条工具栏与 Dock 的 CSS 几何契约);`resourceCanvasBottomToolbar.test.tsx` 15 passed(新增);`resourceCanvasGenerationEntry.test.tsx` 11 passed(新增单类型用例 1 条);`projectResourceLiveIntegration.test.tsx` 25 passed(「生成素材」面板改名断言同步更新);`npm run agc:typecheck` 全绿(**其中的 `check-config.mjs` 报错已因本轮落地调用方而消失**)、`npm run check:encoding`、`git diff --check` 干净。未 commit。 - 关联文档:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`(§3.10 / §7.9 / §8)、`docs/technical/【AGC】栏目画布底部工具栏入口矩阵-2026-09-13.md`、`docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md`(S11 / S11a / §7.3)。 + ## 2026-09-13 Cocos 插件按当前项目类型暴露 - 决策:`agc-cocos-editor` 只有在当前受控项目通过 Cocos Creator 根目录识别(`package.json.creator.version` + 普通 `assets/`)时才暴露插件、面板和 Cocos 工具;无项目或其它项目类型均隐藏并失败关闭。 - 决策:项目切换离开 Cocos 时立即停止已运行的插件实例;启动、面板读取、插件 RPC、Runtime execute 和 DirectProject MCP 工具目录/执行入口全部再次校验项目类型。Cocos 编辑器操作优先经内置插件入口,禁止回退到项目 `extensions/`、`package.json` 插件或第三方 MCP。 - 验证:新增 builtin/plugin host 项目级门禁测试,Direct MCP fixture 补最小 Cocos 工程结构;Rust 定向测试、显式 `cocos-editor-execute` feature 编译、编码检查和 `git diff --check` 已执行。 - ## 2026-09-14 DirectProject Codex 取消路径白名单并启用完整 sandbox - 背景:DirectProject 原先以 `workspaceWrite(writableRoots=[项目根])` 和 `item/fileChange/requestApproval` 的项目根校验限制 Codex 原生文件与命令能力,系统提示词还把 `.agent/`、`.git/`、项目外路径列为不可访问边界。 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index 9447f8b7f..cc3336e05 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -74,4 +74,4 @@ SpacetimeDB 任务统一先读取 `.codex/skills/genarrative-spacetimedb/SKILL.m ## Gitea CI 依赖闭合 -`.gitea/workflows/project-ci.yml` 的客户端门禁拆成四个 job,每个 job 只预热自己会构建的那几份依赖:`AI game creator shell Rust tests` 只预取 AGC 壳 manifest(AGC 壳那份 `Cargo.lock` 的 path 依赖已含 `platform-llm`、`platform-agent`、`agent-runtime-core` 与 `shared-contracts`;`agent-run` smoke 会用 `src-tauri/Cargo.toml` spawn `cargo run`,因此必须同 job),`AI game creator shell Rust crates` 预取 `server-rs/Cargo.toml` 与两个独立 crate,`Native shell tests` 预取桌面壳与 AGC 壳 manifest,`AI game creator shell web tests` 不触碰 Cargo,不预热。前两个 job 只用 cargo 与 node 内建模块,因此不执行 `npm ci`。两个被 `server-rs/Cargo.toml` 排除、且没有提交 `Cargo.lock` 的独立 crate(`agent-runtime-core`、`agent-runtime-orchestration`)只能在 `AI game creator shell Rust crates` 里用不带锁标志的 fetch。AGC 壳的 bin target 单测(约 2466 条)由 `apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs` 编译一次后按 `--list` 名单分 4 片、每片一个进程并行跑,片内保持 `--test-threads=1` 并各自使用独立 `TMPDIR`:当年 libtest 线程并行会互相干扰的是进程内后台锁与异步终态,进程分片不共享这些状态,因此可以并行而无需放宽断言。Backend host workspace tests 使用 `cargo test --locked --workspace --exclude spacetime-module --no-fail-fast`,避免 `spacetime-module` 的 `spacetime-types` feature 统一污染普通领域 crate 的 host 测试;随后单独执行 `cargo test --locked -p spacetime-module --no-fail-fast`,由 `spacetime-module/src/active.rs` 在 host 测试构建期间提供仅测试期的 SpacetimeDB ABI 链接支持,使该 crate 的纯单元测试也纳入 Backend 门禁。`spacetime-module` 的 reducer / procedure 运行时行为仍必须通过真实 SpacetimeDB runtime/integration harness 验证,host 链接支持不得被当作运行时替身。Backend 另外执行 `cargo check --locked -p spacetime-module` 验证模块源码。AGC 壳检查还会运行 `platform-llm` 与 `shared-contracts` 的 server-rs workspace 测试,这些命令以及 AGC 壳测试必须带 `--locked`,避免在测试阶段重新解析 registry index;锁文件发生变化时应先更新受信任 CI 镜像缓存,再重跑门禁。 +`.gitea/workflows/project-ci.yml` 的客户端门禁拆成八个 job,每个 job 只预热自己会构建的那几份依赖:`AI game creator shell Rust shard 1/4` 到 `4/4` 各只预取 AGC 壳 manifest 并各跑一片(AGC 壳那份 `Cargo.lock` 的 path 依赖已含 `platform-llm`、`platform-agent`、`agent-runtime-core` 与 `shared-contracts`),`AI game creator shell Rust smoke` 同样只预取 AGC 壳 manifest(`agent-run` smoke 会用 `src-tauri/Cargo.toml` spawn `cargo run`),`AI game creator shell Rust crates` 预取 `server-rs/Cargo.toml` 与两个独立 crate,`Native shell tests` 预取桌面壳与 AGC 壳 manifest,`AI game creator shell web tests` 不触碰 Cargo,不预热。AGC 壳的 4 个分片 job、smoke job 与 crates job 只用 cargo 与 node 内建模块,因此不执行 `npm ci`。两个被 `server-rs/Cargo.toml` 排除、且没有提交 `Cargo.lock` 的独立 crate(`agent-runtime-core`、`agent-runtime-orchestration`)只能在 `AI game creator shell Rust crates` 里用不带锁标志的 fetch。AGC 壳的 bin target 单测(约 2466 条)由 `apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs` 编译一次后按 `--list` 名单分 4 片:CI 的每个分片 job 用 `--shard-index=` 只跑自己那片,片内保持 `--test-threads=1` 并各自使用独立 `TMPDIR`,片与片之间靠 job 级并发摊开;本地不传 `--shard-index` 时仍是同一条命令把 4 片放进程里并行。不要改回「一个 job 内多进程并行这几片」——同一容器里它们会争抢共享 `HOME`、target 目录与固定临时路径,实测比整套串行还慢。每个分片 job 都会自校验「片并集等于全集且互斥」,因此改分片规则不会静默漏跑。Backend host workspace tests 使用 `cargo test --locked --workspace --exclude spacetime-module --no-fail-fast`,避免 `spacetime-module` 的 `spacetime-types` feature 统一污染普通领域 crate 的 host 测试;随后单独执行 `cargo test --locked -p spacetime-module --no-fail-fast`,由 `spacetime-module/src/active.rs` 在 host 测试构建期间提供仅测试期的 SpacetimeDB ABI 链接支持,使该 crate 的纯单元测试也纳入 Backend 门禁。`spacetime-module` 的 reducer / procedure 运行时行为仍必须通过真实 SpacetimeDB runtime/integration harness 验证,host 链接支持不得被当作运行时替身。Backend 另外执行 `cargo check --locked -p spacetime-module` 验证模块源码。AGC 壳检查还会运行 `platform-llm` 与 `shared-contracts` 的 server-rs workspace 测试,这些命令以及 AGC 壳测试必须带 `--locked`,避免在测试阶段重新解析 registry index;锁文件发生变化时应先更新受信任 CI 镜像缓存,再重跑门禁。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 3015f6be4..c1632f879 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,13 +1,14 @@ # 踩坑与排障记录 -## 2026-09-14 AGC 壳 Rust 套件的「整套单线程」只放宽到「片内单线程」,且分片必须自校验覆盖 +## 2026-09-14 AGC 壳 Rust 套件按「一片一 job」拆分,且分片必须自校验覆盖 -- **现象**:`AI game creator shell Rust tests` 一直是客户端 CI 的关键路径。run 2097 实测 15 分 27 秒,其中 `apps/ai-game-creator-shell/src-tauri/src/main.rs` 的 bin target 单测(2466 条)一条 `cargo test -- --test-threads=1` 串行占 507 秒。 -- **为什么原本是整个 suite 串行**:2026-07-21 `a273377b1` 的判据是「共享 Agent Runtime 后台锁与异步终态在 libtest 并行调度下互相干扰」,即**同进程内**的全局后台锁、异步终态与进程级 static 被交叉触发(另有少数用例自身 spawn `cargo test`,要独占 cargo 的 package cache/target 锁)。这是进程内并行的问题,不是用例之间的数据依赖。 -- **处理**:新增 `apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs`,`cargo test --no-run` 编译一次后用 `--list` 名单把用例切成 4 片,每片一个**独立进程**跑 `--exact <名单> --test-threads=1`,片内串行不变。配套把 `ai-game-creator-shell:check:rust` 拆成 `:rust:crates` 与 `:rust:shell`,CI 新增第 7 个 job `AI game creator shell Rust crates`。 -- **易错点**:① 分片规则必须自校验「片并集等于 `--list` 全集且互斥」,否则改分片方式会静默漏跑门禁;② 每片要拿独立 `TMPDIR`,`tempfile::tempdir()` 默认落在它下面,否则同容器内多片会互踩临时目录(测试里的硬编码 `/tmp/...` 多是「必须拒绝」的负向断言,不是真实读写);③ 不要因为"反正要跑测试"就给分片 job 装 `npm ci`——AGC 壳 Rust 门禁与 `agent-run` smoke 只用 cargo 与 node 内建模块,两份 `npm ci` 正好是达标 7 分钟的主要障碍;④ 壳 job 只需预热 AGC 壳自己的 manifest(其 `Cargo.lock` 的 path 依赖已覆盖 `platform-llm` / `platform-agent` / `agent-runtime-core` / `shared-contracts`),`server-rs` 那份预热属于 crate 级 job;⑤ 分片后 `--test-threads=1` 不再出现在 workflow 里,但它是分片运行器的片内参数,别再往 workflow 里补整套串行命令。 -- **不要做的事**:不要退回「整套 `--test-threads=1`」(507 秒长尾回来了),也不要放开成整套并行(进程内后台锁与异步终态会再互相干扰),更不要用逐项单线程通过来替代整套门禁的稳定性结论。 -- **关联**:`apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs`、`.gitea/workflows/project-ci.yml`、`scripts/check-native-shells.mjs`(`agc-rust-shell` / `agc-rust-crates` 分组)、`package.json`。 +- **现象**:`AI game creator shell Rust tests` 一直是客户端 CI 的关键路径。run 2097 实测 15 分 27 秒,其中 `apps/ai-game-creator-shell/src-tauri` 的 bin target 单测(2466 条)一条 `cargo test -- --test-threads=1` 串行占 507 秒。 +- **为什么原本是整个 suite 串行**:2026-07-21 `a273377b1` 的判据是「共享 Agent Runtime 后台锁与异步终态在 libtest 并行调度下互相干扰」,即**同进程内**的全局后台锁、异步终态与进程级 static 被交叉触发;另有少数用例自身 spawn 当前测试二进制(`std::env::current_exe()`)跑 fixture,会碰容器里共享的 target 与固定临时路径。 +- **处理(现行口径)**:新增 `apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs`:`cargo test --no-run` 编译一次后用 `--list` 名单把用例按 `index % shards` 切成 4 片,CI 的每个分片 job 用 `--shard-index=` 只跑自己那片(`--exact <名单> --test-threads=1`,片内串行),片与片之间靠 **job 级并发**摊开。配套把 `ai-game-creator-shell:check:rust` 拆成 `:rust:crates` 与 `:rust:shell`,AGC 相关门禁在 CI 里共 6 个 job(4 个分片 + smoke + crates)。 +- **反面实验(run 2102,勿重做)**:起先把 4 片放进**同一个 job** 内的 4 个进程并行,结果门禁步骤跑满 18 分钟仍未结束,比整套串行的 507 秒还慢——同一容器内这几片共享 `HOME`、target 目录与固定临时路径,会互相拖慢。因此 `--shard-index` 是 CI 的唯一入口;不带 `--shard-index` 的「单命令内多片并行」只留给本地全量自测。 +- **易错点**:① 分片规则必须自校验「片并集等于 `--list` 全集且互斥」,否则改分片方式会静默漏跑门禁;② 每片要拿独立 `TMPDIR`,`tempfile::tempdir()` 默认落在它下面(测试里的硬编码 `/tmp/...` 多是「必须拒绝」的负向断言,不是真实读写);③ 不要给分片 job 装 `npm ci`——AGC 壳 Rust 门禁与 `agent-run` smoke 只用 cargo 与 node 内建模块,那些 `npm ci` 正是达标 7 分钟的主要障碍;④ 片 job 只需预热 AGC 壳自己的 manifest(其 `Cargo.lock` 的 path 依赖已覆盖 `platform-llm` / `platform-agent` / `agent-runtime-core` / `shared-contracts`),`server-rs` 那份预热属于 crate 级 job;⑤ 分片后 `--test-threads=1` 不再出现在 workflow 里,但它是分片运行器的片内参数,别再往 workflow 里补整套串行命令。 +- **不要做的事**:不要退回「整套 `--test-threads=1`」(507 秒长尾回来了),不要放开成整套并行(同进程内后台锁与异步终态会再互相干扰),也不要在单个 job 内多进程并行多个片(实测比串行还慢)。 +- **关联**:`apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs`、`.gitea/workflows/project-ci.yml`、`scripts/check-native-shells.mjs`(`agc-rust-shard-1..4` / `agc-rust-smoke` / `agc-rust-crates` 分组)、`package.json`。 ## 2026-09-14 根门禁的 `[check:native-shells]