Merge remote-tracking branch 'origin/master' into feat/agc-resource-type-entry

This commit is contained in:
2026-09-14 18:17:10 +08:00
16 changed files with 517 additions and 135 deletions
+151 -15
View File
@@ -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 预热、单独跑。
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@genarrative/ai-game-creator-shell",
"private": true,
"version": "0.1.27",
"version": "0.1.29",
"type": "module",
"scripts": {
"dev": "node scripts/start-tauri-dev.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=<i>` 让**每个 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=<Cargo.toml> --target-kind=lib --no-locked
//
// 参数:
// --shards=<n> 分片数,默认 4
// --concurrency=<n> 同时运行的片数,默认等于分片
// --shard-index=<i> 只跑第 i 片(1..shards);不传则跑全部分片
// --concurrency=<n> 同时运行的片数,默认等于分片数;--shard-index 时恒为 1
// --manifest=<path> Cargo.toml,默认 ../src-tauri/Cargo.toml(相对本脚本)
// --target-kind=<k> bin | lib,默认 bin(本地自测小 crate 时用 lib
// --bin=<name> 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;
+1 -1
View File
@@ -1725,7 +1725,7 @@ dependencies = [
[[package]]
name = "genarrative-ai-game-creator-shell"
version = "0.1.27"
version = "0.1.29"
dependencies = [
"agent-runtime-core",
"axum",
@@ -1,6 +1,6 @@
[package]
name = "genarrative-ai-game-creator-shell"
version = "0.1.27"
version = "0.1.29"
edition = "2021"
publish = false
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Genarrative AI Game Creator",
"version": "0.1.27",
"version": "0.1.29",
"identifier": "world.genarrative.ai-game-creator",
"build": {
"beforeDevCommand": "npm --prefix ../.. run agc:serve",
@@ -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 片,每片作为**独立进程**执行 `<bin> --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=<i>` 只跑自己那片(`--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 按门禁组拆成三个 jobAGC 的 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 <m> -- --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/`、项目外路径列为不可访问边界。
@@ -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 壳 manifestAGC 壳那份 `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=<i>` 只跑自己那片,片内保持 `--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 镜像缓存,再重跑门禁。
+15 -14
View File
@@ -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=<i>` 只跑自己那片(`--exact <名单> --test-threads=1`,片内串行),片与片之间靠 **job 级并发**摊开。配套把 `ai-game-creator-shell:check:rust` 拆成 `:rust:crates``:rust:shell`AGC 相关门禁在 CI 里共 6 个 job4 个分片 + 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] <label>` 是别处按字面量校验的契约
@@ -21,7 +22,7 @@
- **现象**:把 `Native shell tests` 拆成客户端三个 job 后,如果只跑 `npm run check:native-shells:release`,静态契约和壳运行时门禁都不会执行;如果只跑 `--groups=contract``desktop-release-binary-artifact` 又会因为缺少 `build/native/desktop/` 产物而失败。
- **原因**:分组是执行范围,不是"额外检查"。`desktop-release-binary-artifact` 断言依赖同 job 内的 `desktop-shell-stage-release-binary` 步骤,所以它归 `release` 组,不能放进 `contract`;反过来,任何"只跑一组"的命令都不能被当成完整门禁。
- **处理**:分组与 job 的对应关系固定为 `contract`+`shells`+`release``Native shell tests``agc-web``AI game creator shell web tests``agc-rust``AI game creator shell Rust tests``scripts/project-ci-workflow.test.ts` 校验"每个分组恰好被一个 job 调用一次"和"CI 不再调用全量 `npm run check:native-shells`",新增分组必须同步门禁脚本、根脚本与 workflow 三处。
- **处理**:分组与 job 的对应关系固定为 `contract`+`shells`+`release``Native shell tests``agc-web``AI game creator shell web tests``agc-rust-shard-1..4``AI game creator shell Rust shard 1/4 .. 4/4``agc-rust-smoke``AI game creator shell Rust smoke``agc-rust-crates``AI game creator shell Rust crates``scripts/project-ci-workflow.test.ts` 校验"每个分组恰好被一个 job 调用一次"和"CI 不再调用全量 `npm run check:native-shells`",新增分组必须同步门禁脚本、根脚本与 workflow 三处。
- **易错点**:① 拆 job 后 Gitea `master` 分支保护的 required context 要补齐两个新 AGC context,只改 workflow 不改分支保护会让新门禁在合并前不生效;② 每个 job 只预热自己会构建的 Cargo 依赖,`agent-run:smoke` 因为会 spawn `cargo` 必须留在 `agc-rust` 所在 job;③ 本地全量 `npm run check:native-shells` 仍会串行跑完所有分组,用它作为本地完整门禁,不要用单组脚本冒充。
- **关联**`.gitea/workflows/project-ci.yml``scripts/check-native-shells.mjs``scripts/project-ci-workflow.test.ts``.gitea` 分支保护设置。
@@ -31,7 +32,7 @@
- **原因**`collectFiles` 在 Windows 上返回 `src\ActiveApp.tsx`,调用链扫描把该路径原样放进 `scannedFiles`,再与 POSIX 写法的期望清单(`h5HostBridgeRequiredCallChainFiles``src/ActiveApp.tsx`)比较,成员判断恒假。注意 `normalizeModulePath` 不能直接复用:它还会去掉 `.ts/.tsx` 后缀。
- **处理**:新增 `normalizeScannedFilePath`(只统一分隔符、保留后缀)用于 `scannedFiles` 的登记;Linux 上 `split('/').join('/')` 是恒等变换,行为不变。
- **验证**`node scripts/check-native-shells.mjs --groups=contract` 在 Windows 上 0.6 秒通过(修复前同一条命令必红)。
- **同一类限制(未改)**Windows 本机跑 `shells` / `agc-web` / `agc-rust` / `release` 这些**带步骤**的分组会在第一条 npm 步骤直接失败:`spawnSync npm.cmd EINVAL`Node 24 起不能不带 shell 直接执行 `.cmd`;而根 `npm run test` 另有 chmod/0600 语义的 Windows 专属失败)。因此 Windows 本机可用的只有 `--groups=contract`,完整门禁交给 Linux CI;不要为此把 `spawnSync` 改成 `shell: true`(步骤参数里含空格与中文字符串,会被 shell 重新解析)。
- **同一类限制(未改)**Windows 本机跑 `shells` / `agc-web` / `agc-rust-shard-*` / `agc-rust-smoke` / `release` 这些**带步骤**的分组会在第一条 npm 步骤直接失败:`spawnSync npm.cmd EINVAL`Node 24 起不能不带 shell 直接执行 `.cmd`;而根 `npm run test` 另有 chmod/0600 语义的 Windows 专属失败)。因此 Windows 本机可用的只有 `--groups=contract`,完整门禁交给 Linux CI;不要为此把 `spawnSync` 改成 `shell: true`(步骤参数里含空格与中文字符串,会被 shell 重新解析)。
- **关联**`scripts/check-native-shells.mjs``collectH5HostBridgeCallChainFiles` / `normalizeScannedFilePath`
## 2026-09-14 项目写锁的同进程复用判据不能只看 pid
@@ -1210,8 +1211,8 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- 现象:Windows 上执行 `npm run dev:api-server` 时,api-server 在 Cargo 启动阶段失败,日志出现 `error: multiple input filenames provided (first two filenames are ... rustc.exe and -)``/healthz` 无法访问。
- 原因:`server-rs/.cargo/config.toml` 默认配置 `rustc-wrapper = "sccache"`;本地 dev 脚本为了绕过损坏的 sccache 需要覆盖 wrapper。Windows 下如果把 `RUSTC_WRAPPER` 设置为 `rustc`Cargo 会按 wrapper 协议调用 `rustc <真实rustc路径> - ...`,真实 rustc 把 wrapper 传入的 rustc 路径和 stdin `-` 都当输入文件。
- 处理:Windows 本地 dev 脚本应把 `RUSTC_WRAPPER` `CARGO_BUILD_RUSTC_WRAPPER` 显式设为空字符串,让 Cargo 覆盖项目配置并直连真实 rustcLinux 保持 `/usr/bin/env` 绕过 sccache。
- 验证:`npm run test -- scripts/dev.test.ts -t "Windows 下本地 dev Rust env 用空 wrapper 覆盖项目 sccache"`,并`npm run dev:api-server` 拉起后访问实际 api 端口的 `/healthz` 返回 200。
- 处理:Windows 本地 dev 脚本默认把两个 wrapper 设为空字符串;只有用户显式配置 `RUSTC_WRAPPER` `CARGO_BUILD_RUSTC_WRAPPER` 时才处理 sccache。sccache 配置会在当前 dev 编排进程内只执行一次限时真实 `sccache rustc -vV` 探测,并缓存成功或失败结果;失败、超时或两个变量冲突时回退到直接 rustc,避免每次 API / worker 重启重复同步阻塞Linux 保持 `/usr/bin/env` 绕过 sccache。
- 验证:`npm run test -- scripts/dev.test.ts -t "dev scheduler Rust build env"`POSIX 显式配置 sccache 时日志应明确说明绕过并使用直接 rustc;再`npm run dev:api-server` 拉起后访问实际 api 端口的 `/healthz` 返回 200。
- 关联:`scripts/dev.mjs``scripts/dev.test.ts``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## Pingora 直连 80/443 不能只改 env
@@ -2691,7 +2692,7 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- 现象:Cargo 报 `could not execute process sccache ... rustc.exe -vV (never executed)``sccache: error: Timed out waiting for server startup`,或 `sccache: caused by: Failed to send data to or receive data from server / Failed to read response header / failed to fill whole buffer`;真实 `rustc -Vv` 可以执行,但构建在调用包装器时失败。
- 原因:环境、Jenkinsfile 或 `server-rs/.cargo/config.toml` 启用了 `sccache` wrapper,但当前 agent 没有可执行的 `sccache`、PATH 中 shim 损坏,或本地 sccache server/client 通道状态损坏。Windows 本机若配置了 `SCCACHE_OSS_*`sccache daemon 冷启动会先经 OSS/本机代理完成缓存读写检查,再监听 `127.0.0.1:4226`;代理或 OSS 链路慢时,Cargo 的 `sccache rustc -vV` 可能先超时。
- 处理:保留 `server-rs/.cargo/config.toml``rustc-wrapper = "sccache"`;本地 `npm run dev` / `npm run dev:spacetime` / `npm run dev:api-server` `scripts/dev.mjs` 给 Rust 子进程注入直通 wrapper自动绕过项目默认 sccache,避免损坏的 daemon 阻断 `spacetime publish``api-server` 启动;显式设置的非 sccache 自定义 wrapper 会被保留。Windows 本机优先在 `%APPDATA%\Mozilla\sccache\config\config` 写入 `server_startup_timeout_ms = 60000`,拉长 client 等待 daemon 完成 OSS 初始化的时间,然后删除 `server-rs/target/.rustc_info.json` 里缓存的失败探测结果并重跑原始 Cargo 命令。冷启动验证优先用 `sccache --stop-server`,不要在另一个 `cargo` / `rustc` 仍在编译时 `taskkill /F /IM sccache.exe /T`,否则 proc-macro crate 可能被打断并表现为 `serde_derive` / `spacetimedb-bindings-macro``sccache ... exit code: 1`。若只做临时排障,可在 Git Bash 中执行 `RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= cargo build ...`,或在 PowerShell 用 `cargo check -p api-server --config "build.rustc-wrapper=''"` 一次性绕过 wrapper;生产流水线必须先实际执行 `sccache --version`,失败时移除 `RUSTC_WRAPPER` 并回退到直接 `rustc`
- 处理:保留 `server-rs/.cargo/config.toml``rustc-wrapper = "sccache"`;本地 `npm run dev` / `npm run dev:spacetime` / `npm run dev:api-server` 在 Windows 下限时执行真实 wrapper 探测 `sccache rustc -vV`,成功才启用 sccache,缺少命令、daemon 启动超时或 wrapper 返回非零时立即给 Rust 子进程注入 wrapper回退到直接 rustc,避免损坏的 daemon 阻断启动;显式设置的非 sccache 自定义 wrapper 会被保留。Windows 本机优先在 `%APPDATA%\Mozilla\sccache\config\config` 写入 `server_startup_timeout_ms = 60000`,拉长 client 等待 daemon 完成 OSS 初始化的时间,然后删除 `server-rs/target/.rustc_info.json` 里缓存的失败探测结果并重跑原始 Cargo 命令。冷启动验证优先用 `sccache --stop-server`,不要在另一个 `cargo` / `rustc` 仍在编译时 `taskkill /F /IM sccache.exe /T`,否则 proc-macro crate 可能被打断并表现为 `serde_derive` / `spacetimedb-bindings-macro``sccache ... exit code: 1`。若只做临时排障,可在 Git Bash 中执行 `RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= cargo build ...`,或在 PowerShell 用 `cargo check -p api-server --config "build.rustc-wrapper=''"` 一次性绕过 wrapper;生产流水线必须先实际执行 `sccache --version`,失败时移除 `RUSTC_WRAPPER` 并回退到直接 `rustc`
- 验证:`rustc -Vv` 能输出版本;本地 `npm run dev` 能完成 `spacetime publish``api-server` `/healthz`、主站 Vite 和后台 Vite 启动;冷启动后原始 `cargo check -p api-server``cargo check -p spacetime-module` 能通过;`sccache --show-stats` 显示 `Cache location oss, name: genarrative-sccache`,证明原始 Cargo/Jenkins 路径仍可使用 sccache/OSS 缓存;Jenkins 日志出现“未找到可用 sccache,改用 rustc 直接构建”后仍继续真实构建。
- 关联:`scripts/dev.mjs``jenkins/Jenkinsfile.production-stdb-module-build``docs/technical/SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md``docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`
@@ -5405,6 +5406,7 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- **排查顺序**:① 先看失败耗时——几十毫秒说明该入口没等,是等待窗口缺失,不是锁没释放。② 看错误里的 `ownerIsSelf``true` 指向同进程另一条写通道,`false` 指向外部进程;该字段只比 PID,PID 复用会把外人报成自己人,只当线索、不当判据(回收判据另有 `processStartedAt` 兜底)。③ **锁文件在失败后通常已被 Drop 删掉,现场缺文件不否定争用**;同理 `agc_list_registered_assets``pendingOperations: []` 只表示没有在跑的付费生成,与项目写锁无关,不构成“锁没有持有者”的证据。④ `.agent/.manifest.json.lock` 是 manifest 的持久 OS 文件锁(Windows 不共享写句柄 / Unix `flock`),0 字节长期存在是设计如此,不是残留锁,也不要用项目写锁的回收判据去处理它。⑤ 看到“项目写锁路径权限被拒绝”时注意它的含义:这是**等满等待窗口后**的终态改判(Windows 上真实 ACL 拒绝就走这条路),不是某一瞬间的 metadata 观察;反过来,`项目正在被其他写操作占用:…(持锁方身份不可读:锁文件此刻不存在…)` 是零等待入口无法区分拆链窗口与 ACL 拒绝时的并列表述,两者不要互相否定。
- **验证**:Rust 定向覆盖同进程重叠写等待、同轮并行写、有界等待不占 runtime worker、活外部进程持锁带身份、权限拒绝不投影成争用,以及两条平台无关判据用例(重试性只由错误码决定、终态改判三条件);`runtime_project_write_lock_waits_for_delete_pending_target` 继续覆盖带句柄的 delete-pending 必须等到成功。
- **关联**`apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs``apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs`
## 2026-09-11 裸 rustfmt 会把「一个文件」放大成整个模块树,并行工作树里不要盲跑整仓 fmt
- **现象**:只想按 `check:rustfmt` 的报错格式化 1 个 Rust 文件,跑完 `git diff --stat` 却显示 `src/agent/` 下 **50 个文件**被改动(`cargo fmt --all` 之后同样会扫到别人未提交的半成品)。
@@ -5437,7 +5439,6 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- **推广**:任何"进入栏目后应恢复上次 viewport"的用例,在 jsdom 里必须避免经过总览往返;要覆盖往返本身,得先给 `resourceBookSceneSize` 造出非零尺寸。
- **关联**`apps/ai-game-creator-shell/src/view/project-development/index.tsx``openResourceBookChild` / `resourceCanvasFitKeysRef` / 分页画布 layout effect)、`apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts`
## 2026-09-11 新增文件后必须重跑完整 typechecklint-staged 不跑 tscpre-commit 绿不等于类型正确
- **现象**:新增 `ResourceFilterPanel.tsx` 后提交成功(pre-commit 全绿、定向测试通过),但随后补跑 `npm run ai-game-creator-shell:typecheck` 报出真实类型错误:`ResourceFilterPanel.tsx(113,10): error TS2322: Type 'RefObject<HTMLElement | null>' is not assignable to type 'Ref<HTMLDivElement> | undefined'``useRef<HTMLElement | null>` 被挂到了外层 `<div>` 上)。`exit 2`
@@ -5478,7 +5479,7 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- **现象**:在多个 Agent 共用同一 worktree 时,我的一次提交 `28781a420` 里出现两个 hunk:一个是我这次新增的用例(`@@ -1260,6 +1260,51 @@`),另一个是**别人的在途改动**`@@ -9562,37 +9607,83 @@`:他们改「所有资源」测试的期望)。该笔 `107 insertions(+), 16 deletions(-)` 里只有约 45 行是我的。**没有丢内容**(对方的改动只是被冻在我名下的提交里),但**提交描述与实际内容不符**,而且对方那份文件立刻变成"干净",很容易被误以为已经收口。
- **原因**`git add <路径>` 暂存的是该文件**当前工作区的整份内容**,不是"我这一份 diff"。只要对方的编辑已经落在工作区,`git add` 就会连带暂存。**事后回看 `git status --short` 那一行是 `M `(第一列 `M`、第二列空格)——这正是"对方编辑早于我的 `git add` 就已进入工作区"的特征**:索引与工作区一致,所以从状态行上看不出任何异样;若是 `MM`(add 之后工作区又被改过)反而更容易察觉。
- **与前三类的区别**:前三类索引事故都是「**别人的文件**被卷走」——① `git add -A` 卷走别人的在途文件;② `stash pop` 弹出别人的 stash;③ `npx lint-staged --diff=…` 在收尾反向 `git add`。本类是「**同一个文件里别人的 hunk** 被卷走」,而**按文件名做校验的护栏在这里失效**:文件名本来就是我该提交的那一个,`git diff --cached --name-only` 必然通过。
- **处理(正确护栏)**:对"可能被他人同时编辑的文件",把 **`git add <path>` + `git diff --cached`(看**内容**,不只看文件名)+ `git commit` 压进**同一条命令**——窗口越小,对方编辑撞进来的概率越低;并在 `git diff --cached` 的输出里**逐 hunk** 确认"每一个 hunk 都是我这次要写的"。发现混入他人 hunk 后,**不要**在共享树里用 `--amend` / `reset` / `checkout --` / `revert` 做手术(本仓库会话明令禁止),**保持现状并立即上报**,由对方在自己的新提交里接着改(零风险,内容也没丢)。
- **处理(正确护栏)**:对"可能被他人同时编辑的文件",把 **`git add <path>` + `git diff --cached`(看**内容**,不只看文件名)+ `git commit` 压进**同一条命令**——窗口越小,对方编辑撞进来的概率越低;并在 `git diff --cached` 的输出里**逐 hunk** 确认"每一个 hunk 都是我这次要写的"。发现混入他人 hunk 后,**不要**在共享树里用 `--amend` / `reset` / `checkout --` / `revert` 做手术(本仓库会话明令禁止),**保持现状并立即上报\*\*,由对方在自己的新提交里接着改(零风险,内容也没丢)。
- **状态信号速查**`M ` = 已暂存且工作区与索引一致;`MM` = 已暂存 + 工作区又改过;` M` = 只有工作区改动(未暂存)。**`M ` 只能说明"我 add 之后没人再动过",不能说明"这份文件里只有我的改动"**——判断后者唯一可靠的办法是看 `git diff --cached` 的内容。
- **同类隐患(pre-commit 的 lint-staged**`lint-staged` 会对**它格式化过的文件**做 `git add`(日志里的 `prettier --write` 之后紧跟 `Applying modifications from tasks`)。因此"把可能有他人 hunk 的文件交给 lint-staged 格式化"与 `npx lint-staged --diff=…` 是同一类反向 `git add`;要么先与对方约定谁提交该文件,要么等它干净再动。
- **验证**:用内容级复核定案——`git show 28781a420 -- <file> | Select-String '^@@'` 得到两个 hunk`git show``^-` 行里出现对方特有的 `data-resource-book-all-page` / `allSections` 字样。这同时排除了另一种解释("只是 prettier 重排"):重排会表现为 `-`/`+` 成对出现且**语义相同**,而这里 `-` 掉的断言在 `+` 侧并不存在。
@@ -66,7 +66,7 @@ AI 游戏创作客户端使用 `npm run agc`。该入口由 `apps/ai-game-creato
Tauri `beforeDevCommand` 默认与客户端构建并行,不能把上述检查只放在 `beforeDevCommand` 内:选定地址上若已有旧 Vite,Tauri 可能先创建加载旧前端的窗口,随后配套后端才因代理不匹配退出。外层启动器会把 Tauri CLI 放入受控进程树;CLI 正常退出、启动失败或收到终止信号后,POSIX 先向保留的 PGID 发送 `SIGTERM`、有界等待后升级 `SIGKILL`Windows 使用 `taskkill /PID <pid> /T /F`。Windows 下每个长驻服务都经 `cmd.exe /d /s /c` 包装层启动,Ctrl+C 会先杀掉包装层(退出码 `0xC000013A`),因此清理不能只看直接子进程是否存活:`taskkill` 对已退出的 PID 只会失败,必须继续按记录下来的根 PID 遍历,并在退出时按本工作树 `api-server.exe` 绝对路径(以及本次自己拉起的 SpacetimeDB `--data-dir`)做一次身份兜底清扫;`scripts/dev-windows-process.mjs` 是这套判定的唯一实现。Linux 容器中的孤儿后代退出后可能暂时保留为 zombie,`kill(-PGID, 0)` 仍会返回成功;启动器必须结合 `/proc/<pid>/stat` 判断同组是否还存在非 zombie 成员,不能把等待 PID 1 回收误报为清理失败。配套后端和 Vite 仍由 `start-dev-stack.mjs` 各自持有,退出时同样有界收束,避免只剩客户端、Runner、Cargo 或旧订阅进程。排障时同时核对控制台输出的 AGC Vite 实际地址及其 marker、`.app/dev-stack.json` 的实际 API URL 和进程 cwd;不要把“终端已返回”当成客户端及其 Runner 已退出的证据。
Windows 本地 `npm run dev` / `npm run dev:api-server` / `npm run dev:bgfilter-worker` 会用空的 `RUSTC_WRAPPER` / `CARGO_BUILD_RUSTC_WRAPPER` 覆盖 `server-rs/.cargo/config.toml` 里的 `sccache`,从而直连真实 `rustc`。完整栈和 `dev:api-server` 把 API 与 BgFilter worker 作为一个 Rust 重启单元:源码变化时先停两个进程,再先启动并验活 worker、最后启动并验活 API,避免两个 `cargo run` 并发链接同一个 Windows 可执行文件。不要把 wrapper 绕过值写成 `rustc`Cargo 会按 wrapper 协议调用 `rustc <真实rustc路径> - ...`,最终报 `multiple input filenames provided` 并导致 api-server 无法启动。排查本地启动失败时,先看 dev 日志是否出现该错误,再确认脚本注入的 wrapper 为空
Windows 本地 `npm run dev` / `npm run dev:api-server` / `npm run dev:bgfilter-worker` 默认不主动启用 sccache;只有用户通过 `RUSTC_WRAPPER` `CARGO_BUILD_RUSTC_WRAPPER` 显式配置 wrapper 时才进入处理流程。配置为 sccache 时会限时执行真实 wrapper 探测,成功才使用缓存;未安装、不可执行、超时或两个变量冲突时设置为空值,回退到真实 `rustc`,不阻断启动。完整栈和 `dev:api-server` 把 API 与 BgFilter worker 作为一个 Rust 重启单元:源码变化时先停两个进程,再先启动并验活 worker、最后启动并验活 API,避免两个 `cargo run` 并发链接同一个 Windows 可执行文件。不要把 wrapper 绕过值写成 `rustc`Cargo 会按 wrapper 协议调用 `rustc <真实rustc路径> - ...`,最终报 `multiple input filenames provided` 并导致 api-server 无法启动。排查本地启动失败时,先看 dev 日志中的 wrapper 启用、冲突或回退提示
### 本地 Rust 构建缓存与磁盘上限
@@ -262,7 +262,7 @@ npm run check
### Gitea Actions PR 门禁
仓库级 Gitea Actions 工作流固定为 `.gitea/workflows/project-ci.yml`,在向 `master` 推送、创建或更新 PR,以及手工触发时运行。工作流拆成个必须通过的 job。job 声明顺序就是 runner 领取顺序,因此最长尾的 `AI game creator shell Rust tests` 排在最前:并发槽位不足时它必须最先开始,wall clock 才由它而不是由排队决定。
仓库级 Gitea Actions 工作流固定为 `.gitea/workflows/project-ci.yml`,在向 `master` 推送、创建或更新 PR,以及手工触发时运行。工作流拆成十一个必须通过的 job。job 声明顺序就是 runner 领取顺序,因此把 4 个 AGC 壳 Rust 分片 job 排在最前:并发槽位不足时它必须最先开始,AGC 侧的关键路径才由自己而不是由排队决定。
所有 CI job 和 Jenkins Web Build 在根 workspace 安装前都必须确认 `npm --version``10.9.7`。Gitea job 使用预构建镜像内的固定版本;Jenkins Web Build 在每个独立 `bash -lc` 中 source `scripts/jenkins-prepare-npm-env.sh`,首次为 Jenkins 运行用户的版本隔离目录引导同版 npm,后续复用并把该 `bin` 放到 `PATH` 首位。旧固定镜像缺少版本元数据时只能报告 `npm_version=partial` 并由当前 job 的根 `npm ci` 继续校验 lock,不能把过渡状态当作工具链已闭合。
@@ -271,10 +271,11 @@ npm run check
- `Backend tests`:先对 `server-rs/Cargo.lock` 执行带 5 次整命令级有界重试的 `cargo fetch --locked`,再执行 `npm run check:server-rs-ddd``cargo test --locked --workspace --exclude spacetime-module --no-fail-fast``cargo test --locked -p spacetime-module --no-fail-fast``api-server --all-targets` 编译和 `cargo check --locked -p spacetime-module`;普通 workspace host 测试排除 `spacetime-module` 以避免其 `spacetime-types` feature 统一污染领域 crate,模块自身的纯单元测试通过独立 package test 纳入门禁。`spacetime-module` 的 reducer / procedure 运行时行为仍必须通过真实 SpacetimeDB runtime/integration harness 验证,不能把 host 链接支持当作运行时替身。依赖准备必须位于会触发 Cargo build 的 DDD / 产物边界门禁之前,避免锁新增依赖未命中镜像缓存时绕过既有下载重试。runner 安装 `ffmpeg`,避免视频抽帧测试因工具缺失提前返回。依赖真实服务或密钥的测试必须显式 `ignored`,不能让普通 PR job访问现场环境。
- `Native shell tests`:按唯一根 workspace lockfile 安装全部 App 依赖后,用 `npm run check:native-shells:contract``npm run check:native-shells:shells``npm run check:native-shells:release` 分别执行静态契约、H5 / 微信 / Expo / Tauri 桌面壳运行时门禁,以及依赖发布产物的构建 smoke,最后确认桌面壳与 AI 游戏创作壳的 `Cargo.lock` 都没有被构建过程改写。
- `AI game creator shell web tests`:执行 `npm run check:native-shells:agc-web`(即 `npm run ai-game-creator-shell:check:web`AGC 壳 typecheck 与壳内测试)。该分组不触碰 Cargo,因此不预热 Rust 依赖。
- `AI game creator shell Rust tests`只预热 AGC 壳自己那份锁定依赖(`apps/ai-game-creator-shell/src-tauri/Cargo.lock` 的 path 依赖已含 `platform-llm``platform-agent``agent-runtime-core``shared-contracts`),然后执行 `npm run check:native-shells:agc-rust-shell`(即 `npm run ai-game-creator-shell:check:rust:shell``npm run ai-game-creator-shell:agent-run:smoke`):AGC 壳 bin target 的 Rust 单测按 `--list` 名单分 4 片、每片一个进程并行跑,片内保持 `--test-threads=1`,片间各自独立 `TMPDIR`smoke 会用 `src-tauri/Cargo.toml` spawn `cargo run`,所以必须与 AGC 壳的依赖预热同 job。两者都只用 cargo 与 node 内建模块(smoke 脚本只 import `node:*`),因此该 job 不装 npm 依赖。共享 Agent Runtime 后台锁 suite 的串行口径只放宽到「片内串行」:进程分片不会共享进程内后台锁与异步终态,这是它与当年 libtest 线程并行的关键区别,不能退回整套并行
- `AI game creator shell Rust shard 1/4` ~ `4/4`:四个片 job 各自只预热 AGC 壳自己那份锁定依赖(`apps/ai-game-creator-shell/src-tauri/Cargo.lock` 的 path 依赖已含 `platform-llm``platform-agent``agent-runtime-core``shared-contracts`),然后执行 `npm run check:native-shells:agc-rust-shard-<i>`(即分片运行器的 `--shard-index=<i>`):AGC 壳 bin target 的 2466 条 Rust 单测按 `--list` 名单排序后切 4 片,每个 job 只跑自己那片,片内保持 `--test-threads=1`、各片独立 `TMPDIR`,片与片之间靠 job 级并发摊开;每个 job 都会自校验「片并集等于全集且互斥」。**不要**改回「一个 job 里多进程并行这几片」:同一容器内它们共享 `HOME`、target 与固定临时路径,实测(run 2102)比整套串行还慢。这些 job 只用 cargo 与 node 内建模块,因此不装 npm 依赖
- `AI game creator shell Rust smoke`:同样只预热 AGC 壳那份锁定依赖,执行 `npm run check:native-shells:agc-rust-smoke`(即 `npm run ai-game-creator-shell:agent-run:smoke`)。smoke 会用 `src-tauri/Cargo.toml` spawn `cargo run`,单独一个 job 以免把已经压到分钟级的片 job 拖长;只用 cargo 与 node 内建模块(脚本只 import `node:*`),因此不装 npm 依赖。
- `AI game creator shell Rust crates`:预热 `server-rs/Cargo.toml` 与两个无锁独立 crate`agent-runtime-core``agent-runtime-orchestration`)后执行 `npm run check:native-shells:agc-rust-crates`(即 `npm run ai-game-creator-shell:check:rust:crates`),覆盖 `agent-runtime-core``agent-runtime-orchestration``platform-llm``shared-contracts`。这四条命令用的是 server-rs workspace 与独立 crate 的 manifest,属另一套依赖图,因此单独一个 job,也只跑 cargo、不装 npm 依赖。
个 job 合起来覆盖根 `npm run check`,并补齐根检查没有包含的 BgFilter worker smoke harness、无密钥生产巡检 / 发布 / 部署行为 fixture、server-rs DDD、正式 workspace Rust 测试与现役后端编译门禁。客户端门禁的拆分口径是 `scripts/check-native-shells.mjs``--groups=`个分组(`contract``shells``agc-web``agc-rust-crates``agc-rust-shell``release`)各自对应一个 `check:native-shells:<group>` 根脚本,并在 workflow 的某个 job 里被恰好调用一次;不带 `--groups=` 时脚本仍然串行跑全部分组,本地语义不变。`scripts/project-ci-workflow.test.ts` 会同时校验分组清单、根脚本内容、job 覆盖与分片运行器,新增分组必须三处同步。普通 PR CI 不注入业务密钥,不启动真实 API、SpacetimeDB、OSS、支付、图片生成或生产 live smoke;需要现场环境、可变外部状态、Docker 编排或发布凭据的 `check:*` 继续按对应专题和 Jenkins 发布流程执行,不能遍历所有同名前缀脚本冒充 PR 门禁。
十一个 job 合起来覆盖根 `npm run check`,并补齐根检查没有包含的 BgFilter worker smoke harness、无密钥生产巡检 / 发布 / 部署行为 fixture、server-rs DDD、正式 workspace Rust 测试与现役后端编译门禁。客户端门禁的拆分口径是 `scripts/check-native-shells.mjs``--groups=`个分组(`contract``shells``agc-web``agc-rust-crates``agc-rust-shard-1` ~ `agc-rust-shard-4``agc-rust-smoke``release`)各自对应一个 `check:native-shells:<group>` 根脚本,并在 workflow 的某个 job 里被恰好调用一次;不带 `--groups=` 时脚本仍然串行跑全部分组,本地语义不变。`scripts/project-ci-workflow.test.ts` 会同时校验分组清单、根脚本内容、job 覆盖与分片运行器,新增分组必须三处同步。普通 PR CI 不注入业务密钥,不启动真实 API、SpacetimeDB、OSS、支付、图片生成或生产 live smoke;需要现场环境、可变外部状态、Docker 编排或发布凭据的 `check:*` 继续按对应专题和 Jenkins 发布流程执行,不能遍历所有同名前缀脚本冒充 PR 门禁。
PR checkout 必须保留完整 Git 历史,并把 PR base SHA 传给 `SPACETIME_SCHEMA_BASE_REF``check:spacetime-schema` 依赖该基线识别已有表字段删除、改名、重排和改类型;事件给出的基线缺失或本地不可解析时必须直接失败,不能退化为空差异检查。Gitea 的 PR checkout 是 PR head,不是与目标分支的预合并 commit,因此 workflow 还会验证 PR head 包含事件中的最新 base commit;分支保护必须继续开启“PR 过期禁止合并”,过期分支先更新再重跑。向 `master` 直接推送时使用 push before SHA;手工触发先尝试 `origin/master`,若它与 `HEAD` 相同则改用 `HEAD^`,仍无法得到不同提交时失败关闭。
@@ -293,13 +294,13 @@ bash scripts/gitea-ci-job-image.sh export /仓库外受控路径/genarrative-git
bash scripts/gitea-ci-job-image.sh load-runner
```
执行账号只要有权访问宿主 Docker API 并管理 runner 容器即可,不强制使用 root;无该权限时由 runner 运维人员执行。更新顺序必须是 `build/verify -> export 仓库外镜像归档与 SHA-256 sidecar -> load-runner -> 确认无活跃 job -> 备份当前 config -> 增加或替换 label -> docker restart --timeout 660 gitea-runner``--timeout 660` 只是停止宽限,不是 drain APIrootless DinD supervisor 可能同时停止内层 dockerd,因此重启前必须确认 Gitea 没有 `in_progress` run 且内层 `docker ps` 为空。config 和镜像归档只保存到仓库外受控位置,不在文档、仓库或日志中记录注册信息。重启后先重跑真实 PR 的个 job,复核隔离边界并确认全部通过,再清理旧镜像。回滚时先把 workflow 的 `runs-on` 改回 `ubuntu-latest`,再恢复 config 备份并重启 runner。
执行账号只要有权访问宿主 Docker API 并管理 runner 容器即可,不强制使用 root;无该权限时由 runner 运维人员执行。更新顺序必须是 `build/verify -> export 仓库外镜像归档与 SHA-256 sidecar -> load-runner -> 确认无活跃 job -> 备份当前 config -> 增加或替换 label -> docker restart --timeout 660 gitea-runner``--timeout 660` 只是停止宽限,不是 drain APIrootless DinD supervisor 可能同时停止内层 dockerd,因此重启前必须确认 Gitea 没有 `in_progress` run 且内层 `docker ps` 为空。config 和镜像归档只保存到仓库外受控位置,不在文档、仓库或日志中记录注册信息。重启后先重跑真实 PR 的十一个 job,复核隔离边界并确认全部通过,再清理旧镜像。回滚时先把 workflow 的 `runs-on` 改回 `ubuntu-latest`,再恢复 config 备份并重启 runner。
个 job 先运行镜像内 `genarrative-gitea-checkout`,再以 `GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1` 执行 `scripts/check-gitea-ci-job-image.sh`,校验 Node 与 npm 固定版本、仓库 Rust toolchain、受信任 PATH、四份缓存锁命中状态、原生命令、pkg-config 依赖、完整 bwrap sandbox 和 Chrome headless。运行时发现锁不匹配时必须输出对应 `*_cache_lock=partial` 和 Actions warning,提示可信分支落地后刷新镜像,不能把陈旧缓存误报为闭合。`RUSTUP_AUTO_INSTALL=0`,因此仓库 `rust-toolchain.toml` 变更必须先更新镜像,不能让 job 现场下载。每个 job 仍独立运行一次根 `npm ci`,以唯一 workspace lock 验证 PR 的全部 App 依赖;统一通过 `scripts/ci-npm-ci-with-retry.sh` 做最多 3 次整命令级有界重试,同时保留 `NPM_CONFIG_PREFER_OFFLINE=true` 和 npm 自身 10 次 fetch retry。命中镜像 cache 时只做干净解包,lock 变化时允许补齐差量。不在镜像内烘入 `node_modules`,也不挂载跨 PR 可写缓存。任何 job 的 sandbox canary 失败都必须停止,不允许跳过。Cargo 通过受控 proxy 下载 lock 差量时继续关闭 HTTP multiplexing,并设置 `CARGO_NET_RETRY=10`
十一个 job 先运行镜像内 `genarrative-gitea-checkout`,再以 `GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1` 执行 `scripts/check-gitea-ci-job-image.sh`,校验 Node 与 npm 固定版本、仓库 Rust toolchain、受信任 PATH、四份缓存锁命中状态、原生命令、pkg-config 依赖、完整 bwrap sandbox 和 Chrome headless。运行时发现锁不匹配时必须输出对应 `*_cache_lock=partial` 和 Actions warning,提示可信分支落地后刷新镜像,不能把陈旧缓存误报为闭合。`RUSTUP_AUTO_INSTALL=0`,因此仓库 `rust-toolchain.toml` 变更必须先更新镜像,不能让 job 现场下载。需要 `node_modules` job 仍各自独立运行一次根 `npm ci`,以唯一 workspace lock 验证 PR 的全部 App 依赖;4 个 AGC 壳 Rust 分片 job、`AI game creator shell Rust smoke``AI game creator shell Rust crates` 是纯 cargo 门禁(只用 cargo 与 node 内建模块),显式不装 npm 依赖,这也由 `scripts/project-ci-workflow.test.ts` 钉住。`npm ci` 统一通过 `scripts/ci-npm-ci-with-retry.sh` 做最多 3 次整命令级有界重试,同时保留 `NPM_CONFIG_PREFER_OFFLINE=true` 和 npm 自身 10 次 fetch retry。命中镜像 cache 时只做干净解包,lock 变化时允许补齐差量。不在镜像内烘入 `node_modules`,也不挂载跨 PR 可写缓存。任何 job 的 sandbox canary 失败都必须停止,不允许跳过。Cargo 通过受控 proxy 下载 lock 差量时继续关闭 HTTP multiplexing,并设置 `CARGO_NET_RETRY=10`
站点 stack 仍由宿主受控目录管理,`.env`、runner 注册文件和数据库凭据不进入仓库。Compose 必须在 helper/container 内把该目录挂到与宿主相同的绝对路径再执行;挂载到不同路径会让相对 bind source 被 Docker daemon 解析到错误的宿主目录并启动空数据。升级或 runner 迁移前先停止 Gitea 写入,并把 Gitea 冷快照、数据库导出、compose/env 与 runner config/.runner 保存到仓库外受控备份位置。备份文件、绝对宿主配置和注册 token 不得提交 Git,也不在共享文档中记录具体路径或注册内容。
workflow 首次成功运行后,在 Gitea `master` 分支保护中把 `Project CI / Repository checks (pull_request)``Project CI / Frontend tests (pull_request)``Project CI / Backend tests (pull_request)``Project CI / Native shell tests (pull_request)``Project CI / AI game creator shell web tests (pull_request)``Project CI / AI game creator shell Rust tests (pull_request)``Project CI / AI game creator shell Rust crates (pull_request)` 个完整 context 都设为合并必需检查,并从最近一周已上报 context 表复核名称后再保存。客户端 CI 拆分的迁移是**追加式**的:旧四个 job 名继续上报,但 `Native shell tests` 的内容已收窄到壳级与发布构建门禁,因此新增的 AGC context 必须补进必需检查,否则 AGC 门禁在合并前不生效。不能只填裸 job 名,否则无法匹配 Gitea 实际上报的 `<workflow> / <job> (<event>)`。只提交 workflow 文件不会自动创建 runner,也不会自动修改分支保护;如果 Actions 长时间停留在等待状态,先到仓库或组织的 Actions runner 页面确认存在在线、带 `genarrative-ci` 标签的 runner,再检查精确 Image ID 是否已装入内层 Docker。
workflow 首次成功运行后,在 Gitea `master` 分支保护中把 `Project CI / Repository checks (pull_request)``Project CI / Frontend tests (pull_request)``Project CI / Backend tests (pull_request)``Project CI / Native shell tests (pull_request)``Project CI / AI game creator shell web tests (pull_request)``Project CI / AI game creator shell Rust shard 1/4 (pull_request)` ~ `Project CI / AI game creator shell Rust shard 4/4 (pull_request)``Project CI / AI game creator shell Rust smoke (pull_request)``Project CI / AI game creator shell Rust crates (pull_request)` 十一个完整 context 都设为合并必需检查,并从最近一周已上报 context 表复核名称后再保存。客户端 CI 拆分的迁移是**追加式**的:旧 job 名继续上报,但 `Native shell tests` 的内容已收窄到壳级与发布构建门禁,因此新增的 AGC context 必须补进必需检查,否则 AGC 门禁在合并前不生效。不能只填裸 job 名,否则无法匹配 Gitea 实际上报的 `<workflow> / <job> (<event>)`。只提交 workflow 文件不会自动创建 runner,也不会自动修改分支保护;如果 Actions 长时间停留在等待状态,先到仓库或组织的 Actions runner 页面确认存在在线、带 `genarrative-ci` 标签的 runner,再检查精确 Image ID 是否已装入内层 Docker。
master 日常交付必须禁止直接 push,只允许经 PR 在当前 head 的七个 required context 全绿后合并;本地 `pre-commit` 的 staged ESLint/Prettier 和 master `pre-push` 的 Repository checks parity 只用于提前发现问题,可被 `--no-verify` 绕过,不能充当服务端权威门禁。紧急直推白名单如需保留,应按人员和时限最小化,并要求执行同一 `npm run check:repository-ci <base> <head>` 后回读 push CI。
+1 -1
View File
@@ -95,7 +95,7 @@
},
"apps/ai-game-creator-shell": {
"name": "@genarrative/ai-game-creator-shell",
"version": "0.1.27",
"version": "0.1.29",
"dependencies": {
"@cubone/react-file-manager": "^1.35.0",
"@genarrative/image-canvas-core": "0.1.0",
+5 -1
View File
@@ -207,7 +207,11 @@
"check:native-shells:shells": "node scripts/check-native-shells.mjs --groups=shells",
"check:native-shells:agc-web": "node scripts/check-native-shells.mjs --groups=agc-web",
"check:native-shells:agc-rust-crates": "node scripts/check-native-shells.mjs --groups=agc-rust-crates",
"check:native-shells:agc-rust-shell": "node scripts/check-native-shells.mjs --groups=agc-rust-shell",
"check:native-shells:agc-rust-shard-1": "node scripts/check-native-shells.mjs --groups=agc-rust-shard-1",
"check:native-shells:agc-rust-shard-2": "node scripts/check-native-shells.mjs --groups=agc-rust-shard-2",
"check:native-shells:agc-rust-shard-3": "node scripts/check-native-shells.mjs --groups=agc-rust-shard-3",
"check:native-shells:agc-rust-shard-4": "node scripts/check-native-shells.mjs --groups=agc-rust-shard-4",
"check:native-shells:agc-rust-smoke": "node scripts/check-native-shells.mjs --groups=agc-rust-smoke",
"check:native-shells:release": "node scripts/check-native-shells.mjs --groups=release"
},
"dependencies": {
+57 -13
View File
@@ -87,16 +87,22 @@ const aiGameCreatorViteConfigSource = fs.readFileSync(
// - agc-webAI 游戏创作壳的前端门禁(typecheck 与壳内测试,不触碰 Cargo)。
// - agc-rust-cratesAGC 壳依赖的共享 / 平台 crate 测试(server-rs workspace 加两个
// 无锁独立 crate),只需 server-rs 侧的依赖预热。
// - agc-rust-shellAGC 壳自身的 Rust 门禁(bin target 单测按名单分片并行、片内保持
// `--test-threads=1`)与会用 `src-tauri/Cargo.toml` spawn `cargo run` 的 agent-run
// smoke,只需 AGC 壳自己的依赖预热
// - agc-rust-shard-1..4AGC 壳自身的 Rust bin target 单测按名单切成 4 片,一片一个
// 分组(CI 里就是一个 job),片内仍保持 `--test-threads=1`;每个分片分组都会做一次
// 「片并集等于全集且互斥」的自校验
// - agc-rust-smoke:会用 `src-tauri/Cargo.toml` spawn `cargo run` 的 agent-run smoke。
// 与分片分开,免得把已经压到 4 分钟级的片 job 拖长。
// - release:发布构建 smoke 和依赖发布产物的落盘断言。
const nativeShellGateGroups = [
'contract',
'shells',
'agc-web',
'agc-rust-crates',
'agc-rust-shell',
'agc-rust-shard-1',
'agc-rust-shard-2',
'agc-rust-shard-3',
'agc-rust-shard-4',
'agc-rust-smoke',
'release',
];
const requestedNativeShellGroups = readRequestedNativeShellGroups(
@@ -2314,9 +2320,9 @@ const steps = [
},
// AI 游戏创作壳原先一步串完 typecheck、壳内测试、共享 / 平台 crate 测试和
// 串行壳测试,CI 因此只有一条 10 分钟以上的长尾。这里按同一组命令切成
// web、rust-crates、rust-shell 三段,顺序与 `npm run ai-game-creator-shell:check`
// 完全一致,但允许 CI 把三段放进不同 job 并行执行本地全量运行仍然是
// web -> rust(crates -> shell) -> smoke 原顺序。
// web、rust-crates、rust 分片、smoke 四段,整体顺序与 `npm run ai-game-creator-shell:check`
// 完全一致;CI 把每段(以及每个 rust 分片)放进不同 job 并行执行本地全量运行仍然是
// web -> rust(crates -> shards) -> smoke 原顺序。
{
group: 'agc-web',
label: 'ai-game-creator-shell-check-web',
@@ -2330,15 +2336,53 @@ const steps = [
args: ['run', 'ai-game-creator-shell:check:rust:crates'],
},
{
group: 'agc-rust-shell',
label: 'ai-game-creator-shell-check-rust-shell',
group: 'agc-rust-shard-1',
label: 'ai-game-creator-shell-check-rust-shard-1',
command: npmCommand,
args: ['run', 'ai-game-creator-shell:check:rust:shell'],
args: [
'run',
'ai-game-creator-shell:check:rust:shell',
'--',
'--shard-index=1',
],
},
// agent-run smoke 会用 `src-tauri/Cargo.toml` spawn `cargo run`,因此与壳测试同组,
// 保证它落在已经预热 AGC 壳 Cargo 依赖的 job 里。
{
group: 'agc-rust-shell',
group: 'agc-rust-shard-2',
label: 'ai-game-creator-shell-check-rust-shard-2',
command: npmCommand,
args: [
'run',
'ai-game-creator-shell:check:rust:shell',
'--',
'--shard-index=2',
],
},
{
group: 'agc-rust-shard-3',
label: 'ai-game-creator-shell-check-rust-shard-3',
command: npmCommand,
args: [
'run',
'ai-game-creator-shell:check:rust:shell',
'--',
'--shard-index=3',
],
},
{
group: 'agc-rust-shard-4',
label: 'ai-game-creator-shell-check-rust-shard-4',
command: npmCommand,
args: [
'run',
'ai-game-creator-shell:check:rust:shell',
'--',
'--shard-index=4',
],
},
// agent-run smoke 会用 `src-tauri/Cargo.toml` spawn `cargo run`,因此单独一个分组,
// 落在同样预热了 AGC 壳 Cargo 依赖的 job 里。
{
group: 'agc-rust-smoke',
label: 'ai-game-creator-shell-agent-run-smoke',
command: npmCommand,
args: ['run', 'ai-game-creator-shell:agent-run:smoke'],
+60 -13
View File
@@ -146,9 +146,34 @@ function applyLocalFfmpegEnv(env, platform = process.platform) {
return env;
}
function resolveLocalDevRustcWrapperBypass() {
// Windows 下不能把 rustc 自身当成 Cargo wrapper;空值会覆盖仓库 .cargo/config.toml 中的 sccache。
return process.platform === 'win32' ? '' : '/usr/bin/env';
const localSccacheProbeCache = new Map();
function resolveLocalDevRustcWrapperBypass(configuredWrapper, options = {}) {
if (process.platform !== 'win32') {
return '/usr/bin/env';
}
const sccacheAvailable =
options.sccacheAvailable ??
(() => {
const cacheKey = `${process.platform}:${configuredWrapper}`;
const cached = localSccacheProbeCache.get(cacheKey);
if (cached !== undefined) {
return cached;
}
const result = spawnSync(configuredWrapper, ['rustc', '-vV'], {
cwd: repoRoot,
encoding: 'utf8',
shell: true,
windowsHide: true,
timeout: options.sccacheProbeTimeoutMs ?? 10_000,
});
const available = !result.error && result.status === 0;
localSccacheProbeCache.set(cacheKey, available);
return available;
})();
return sccacheAvailable ? configuredWrapper : '';
}
const SERVICE_NAMES = [
@@ -583,21 +608,43 @@ function buildLocalRustProcessEnv(env, options = {}) {
String(mergedEnv.RUSTC_WRAPPER ?? '').trim(),
String(mergedEnv.CARGO_BUILD_RUSTC_WRAPPER ?? '').trim(),
].filter(Boolean);
const customWrapper = wrappers.find(
(wrapper) => !isSccacheRustcWrapper(wrapper),
);
if (customWrapper) {
mergedEnv.RUSTC_WRAPPER = customWrapper;
mergedEnv.CARGO_BUILD_RUSTC_WRAPPER = customWrapper;
const uniqueWrappers = [...new Set(wrappers)];
if (uniqueWrappers.length > 1) {
mergedEnv.RUSTC_WRAPPER = '';
mergedEnv.CARGO_BUILD_RUSTC_WRAPPER = '';
if (options.log !== false) {
console.warn(
'[dev:rust] RUSTC_WRAPPER 与 CARGO_BUILD_RUSTC_WRAPPER 配置冲突,回退到直接 rustc。',
);
}
return mergedEnv;
}
const rustcWrapperBypass = resolveLocalDevRustcWrapperBypass();
mergedEnv.RUSTC_WRAPPER = rustcWrapperBypass;
mergedEnv.CARGO_BUILD_RUSTC_WRAPPER = rustcWrapperBypass;
const configuredWrapper = uniqueWrappers[0] ?? '';
if (configuredWrapper && !isSccacheRustcWrapper(configuredWrapper)) {
mergedEnv.RUSTC_WRAPPER = configuredWrapper;
mergedEnv.CARGO_BUILD_RUSTC_WRAPPER = configuredWrapper;
return mergedEnv;
}
const rustcWrapper = configuredWrapper
? resolveLocalDevRustcWrapperBypass(configuredWrapper, options)
: '';
mergedEnv.RUSTC_WRAPPER = rustcWrapper;
mergedEnv.CARGO_BUILD_RUSTC_WRAPPER = rustcWrapper;
if (options.log !== false) {
const isPosixSccacheBypass =
configuredWrapper &&
isSccacheRustcWrapper(configuredWrapper) &&
process.platform !== 'win32';
console.warn(
'[dev:rust] 本地 dev 构建绕过项目 sccache wrapper,避免缓存进程异常阻断启动。',
isPosixSccacheBypass
? '[dev:rust] POSIX 本地 dev 构建绕过 sccache,使用直接 rustc。'
: rustcWrapper
? '[dev:rust] 本地 dev 构建启用 sccache wrapper。'
: configuredWrapper
? '[dev:rust] sccache wrapper 探测失败,回退到直接 rustc。'
: '[dev:rust] 未显式配置 Rust wrapper,使用直接 rustc。',
);
}
return mergedEnv;
+94 -23
View File
@@ -657,34 +657,55 @@ describe('dev scheduler local worker cleanup', () => {
});
describe('dev scheduler Rust build env', () => {
test('local dev Rust env bypasses project sccache wrapper', () => {
const env = buildLocalRustProcessEnv(
{
RUSTC_WRAPPER: '/usr/bin/sccache',
CARGO_BUILD_RUSTC_WRAPPER: 'sccache',
},
{ log: false },
test('Windows 下未显式配置 wrapper 时默认关闭 sccache', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(
process,
'platform',
);
expect(env.RUSTC_WRAPPER).not.toBe('/usr/bin/sccache');
expect(env.RUSTC_WRAPPER).not.toBe('sccache');
expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe(env.RUSTC_WRAPPER);
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32',
});
try {
const env = buildLocalRustProcessEnv(
{},
{ log: false, sccacheAvailable: true },
);
expect(env.RUSTC_WRAPPER).toBe('');
expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('');
} finally {
if (originalPlatform)
Object.defineProperty(process, 'platform', originalPlatform);
}
});
test('local dev Rust env keeps healthy custom wrapper untouched', () => {
const env = buildLocalRustProcessEnv(
{
RUSTC_WRAPPER: 'custom-wrapper',
CARGO_BUILD_RUSTC_WRAPPER: 'sccache',
},
{ log: false },
test('POSIX 显式配置 sccache 时日志说明实际绕过', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(
process,
'platform',
);
expect(env.RUSTC_WRAPPER).toBe('custom-wrapper');
expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('custom-wrapper');
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'linux',
});
try {
const env = buildLocalRustProcessEnv(
{ RUSTC_WRAPPER: 'sccache', CARGO_BUILD_RUSTC_WRAPPER: 'sccache' },
{ log: true },
);
expect(env.RUSTC_WRAPPER).toBe('/usr/bin/env');
expect(warn).toHaveBeenCalledWith(
'[dev:rust] POSIX 本地 dev 构建绕过 sccache,使用直接 rustc。',
);
} finally {
warn.mockRestore();
if (originalPlatform)
Object.defineProperty(process, 'platform', originalPlatform);
}
});
test('Windows 下本地 dev Rust env 用空 wrapper 覆盖项目 sccache', () => {
test('local dev Rust env enables available sccache wrapper', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(
process,
'platform',
@@ -700,7 +721,48 @@ describe('dev scheduler Rust build env', () => {
RUSTC_WRAPPER: 'sccache',
CARGO_BUILD_RUSTC_WRAPPER: 'sccache',
},
{ log: false },
{ log: false, sccacheAvailable: true },
);
expect(env.RUSTC_WRAPPER).toBe('sccache');
expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe(env.RUSTC_WRAPPER);
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform);
}
}
});
test('Rust wrapper 配置为同一个自定义 wrapper 时保持不变', () => {
const env = buildLocalRustProcessEnv(
{
RUSTC_WRAPPER: 'custom-wrapper',
CARGO_BUILD_RUSTC_WRAPPER: 'custom-wrapper',
},
{ log: false },
);
expect(env.RUSTC_WRAPPER).toBe('custom-wrapper');
expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('custom-wrapper');
});
test('Windows 下未安装 sccache 时回退到空 wrapper', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(
process,
'platform',
);
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32',
});
try {
const env = buildLocalRustProcessEnv(
{
RUSTC_WRAPPER: 'sccache',
CARGO_BUILD_RUSTC_WRAPPER: 'sccache',
},
{ log: false, sccacheAvailable: false },
);
expect(env.RUSTC_WRAPPER).toBe('');
@@ -711,6 +773,15 @@ describe('dev scheduler Rust build env', () => {
}
}
});
test('Rust wrapper 配置冲突时回退到空 wrapper', () => {
const env = buildLocalRustProcessEnv(
{ RUSTC_WRAPPER: 'sccache', CARGO_BUILD_RUSTC_WRAPPER: 'custom-wrapper' },
{ log: false },
);
expect(env.RUSTC_WRAPPER).toBe('');
expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('');
});
});
describe('dev scheduler stack state file', () => {
+57 -16
View File
@@ -41,16 +41,24 @@ const jobNames = [
'backend-tests',
'native-shell-tests',
'ai-game-creator-shell-web-tests',
'ai-game-creator-shell-rust-tests',
'ai-game-creator-shell-rust-shard-1',
'ai-game-creator-shell-rust-shard-2',
'ai-game-creator-shell-rust-shard-3',
'ai-game-creator-shell-rust-shard-4',
'ai-game-creator-shell-rust-smoke',
'ai-game-creator-shell-rust-crates',
] as const;
// 这个 job 是纯 cargo 门禁:AGC 壳的 Rust 套件与 agent-run smoke 只用 cargo 与
// 这个 job 是纯 cargo 门禁:AGC 壳的 Rust 分片与 agent-run smoke 只用 cargo 与
// node 内建模块(smoke 脚本只 import `node:*`),crate 级测试只跑 cargo 命令,
// 都不需要 node_modules。省掉两份 `npm ci`(各 1~3 分钟)是把客户端 Rust 关键路径
// 都不需要 node_modules。省掉这些 `npm ci`(各 1~3 分钟)是把客户端 Rust 关键路径
// 压到 7 分钟以内的前提,因此这里显式允许它们不装 npm 依赖。
const jobsWithoutNpmInstall: readonly string[] = [
'ai-game-creator-shell-rust-tests',
'ai-game-creator-shell-rust-shard-1',
'ai-game-creator-shell-rust-shard-2',
'ai-game-creator-shell-rust-shard-3',
'ai-game-creator-shell-rust-shard-4',
'ai-game-creator-shell-rust-smoke',
'ai-game-creator-shell-rust-crates',
];
@@ -69,7 +77,11 @@ const nativeShellGateGroupScripts = {
shells: 'npm run check:native-shells:shells',
'agc-web': 'npm run check:native-shells:agc-web',
'agc-rust-crates': 'npm run check:native-shells:agc-rust-crates',
'agc-rust-shell': 'npm run check:native-shells:agc-rust-shell',
'agc-rust-shard-1': 'npm run check:native-shells:agc-rust-shard-1',
'agc-rust-shard-2': 'npm run check:native-shells:agc-rust-shard-2',
'agc-rust-shard-3': 'npm run check:native-shells:agc-rust-shard-3',
'agc-rust-shard-4': 'npm run check:native-shells:agc-rust-shard-4',
'agc-rust-smoke': 'npm run check:native-shells:agc-rust-smoke',
release: 'npm run check:native-shells:release',
} as const;
@@ -391,7 +403,7 @@ describe('project CI workflow', () => {
nativeShellGateScript.indexOf('const nativeShellGateGroups = ['),
),
)
.matchAll(/'([a-z-]+)'/gu),
.matchAll(/'([a-z0-9-]+)'/gu),
].map((match) => match[1]);
expect(declaredGroups).toEqual(Object.keys(nativeShellGateGroupScripts));
@@ -405,16 +417,40 @@ describe('project CI workflow', () => {
expect(webJob).toContain('run: npm run check:native-shells:agc-web');
expect(webJob).not.toContain('cargo fetch');
const rustJob = jobSection('ai-game-creator-shell-rust-tests');
expect(rustJob).toContain(
'run: npm run check:native-shells:agc-rust-shell',
// 壳 bin 单测按名单分 4 片,一片一个 job:每个片 job 只跑自己那片,且只预热 AGC 壳
// 自己那份锁定依赖(server-rs 那份归 crate 级 job)。
for (const [index, jobName] of [
[1, 'ai-game-creator-shell-rust-shard-1'],
[2, 'ai-game-creator-shell-rust-shard-2'],
[3, 'ai-game-creator-shell-rust-shard-3'],
[4, 'ai-game-creator-shell-rust-shard-4'],
] as const) {
const shardJob = jobSection(jobName);
expect(shardJob).toContain(
`run: npm run check:native-shells:agc-rust-shard-${index}`,
);
expect(shardJob).toContain(
'apps/ai-game-creator-shell/src-tauri/Cargo.toml',
);
expect(shardJob).toContain('cargo fetch --locked');
expect(shardJob).not.toContain('server-rs/Cargo.toml');
}
// 整套用例不能再作为一条命令串行跑完:每个片 job 都必须落到分片运行器的
// `--shard-index` 上,4 个 index 各一次。
for (const index of [1, 2, 3, 4]) {
expect(nativeShellGateScript).toContain(`'--shard-index=${index}'`);
}
expect(
nativeShellGateScript.match(/ai-game-creator-shell:check:rust:shell/gu),
).toHaveLength(4);
const smokeJob = jobSection('ai-game-creator-shell-rust-smoke');
expect(smokeJob).toContain(
'run: npm run check:native-shells:agc-rust-smoke',
);
expect(rustJob).toContain(
'apps/ai-game-creator-shell/src-tauri/Cargo.toml',
);
expect(rustJob).toContain('cargo fetch --locked');
// 壳 job 只预热 AGC 壳自己那份锁定依赖:server-rs 那份归 crate 级 job。
expect(rustJob).not.toContain('server-rs/Cargo.toml');
expect(smokeJob).toContain('cargo fetch --locked');
expect(smokeJob).not.toContain('server-rs/Cargo.toml');
const cratesJob = jobSection('ai-game-creator-shell-rust-crates');
expect(cratesJob).toContain(
@@ -445,8 +481,9 @@ describe('project CI workflow', () => {
'node apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs --shards=4',
);
// 整套用例不再作为一条 `cargo test -- --test-threads=1` 命令串行跑:CI 与本地都走
// 分片运行器,片内串行、片间并行
// 分片运行器,片内串行,片与片之间靠 job 级并发摊开
expect(workflow).not.toContain('-- --test-threads=1');
expect(workflow).not.toMatch(/^ {8}run: .*--test-threads/mu);
});
it('keeps the shell shard runner exhaustive over discovered tests', () => {
@@ -464,6 +501,10 @@ describe('project CI workflow', () => {
expect(shardRunner).toContain("'--test-threads=1'");
expect(shardRunner).toContain('TMPDIR: shardTmpDirectory');
expect(shardRunner).toContain("'--exact'");
// `--shard-index` 是 CI 的「一个 job 一片」入口:只跑指定片,但覆盖自校验照旧
// 针对 `--list` 全集执行,所以改分片规则仍然会在每个 job 上暴露。
expect(shardRunner).toContain("case 'shard-index':");
expect(shardRunner).toContain('must be within --shards');
});
it('prefetches the excluded standalone Rust crates before the shared crate gates', () => {