Merge origin/fix/ci-master-red(8b11dc4e)进 fix/agc-composer-layout
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m54s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 5m46s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 4m41s
Project CI / Frontend tests (pull_request) Successful in 3m28s
Project CI / Backend tests (pull_request) Successful in 7m31s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m41s
Project CI / Native shell tests (pull_request) Successful in 7m18s
Project CI / Repository checks (pull_request) Successful in 5m53s

- 目的:并入 #609 的共享红修复(A Nginx SPA allowlist 缺 /pay、/profile/payment;B external_mcp::semantic 写死工具数断言;C tracing callsite interest 竞态;D AGC 壳 Rust shard-4 两条偶发断言),让本分支新 head 的 CI 能全绿
- 合并未产生冲突;#609 的 18 个文件原样在位(抽样 md5 与 8b11dc4e 一致:deploy 三份 nginx 模板、scripts/check-nginx-spa-routes.mjs、server-rs api-server/app.rs、src/routing/activeAppPageRoutes.ts)
- pitfalls.md 两侧条目都保留:本分支的 #600「输入区控件叠在一起」条目 + #609 的三条 2026-10-04 条目(tracing span 调用点缓存 / 通知计数与 graceful terminate 串台 / SPA 深链前缀路由)
- #609 没有改 apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts,故与本 PR 的 composer 几何断言无重叠;本 PR 也未改 #609 引入的任何文件
This commit is contained in:
2026-10-04 03:41:15 +08:00
19 changed files with 540 additions and 51 deletions
+27 -1
View File
@@ -4161,7 +4161,7 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- 现象:target 注册了 SIGTERM 清理逻辑,但 `command.terminate` 只偶尔出现 stopped marker;耗时 300-500ms 的清理经常被提前截断。
- 原因:如果先向 wrapper/bwrap/trampoline/target 共用的外层进程组发送 SIGTERM,wrapper 会先退出,bwrap 的 die-with-parent 随即收走 namespace;名义上的 800ms 宽限并没有真正留给 target。
- 处理:process-session target 在 child pre-exec 内暂时屏蔽 SIGTTOU,完成 setpgid + PTY slave tcsetpgrp 并恢复信号掩码后才 exec;不能先 spawn 到后台组再由 parent 设前台,否则 target 可能已经因 immediate read 收到 SIGTTIN。Runtime 通过两级私有控制通道请求 trampoline 只向 target group 发 SIGTERM。direct leader 退出后 trampoline 继续检查同组后代,外层 wrapper/bwrap 在最多 800ms 宽限期保持存活,超时才强杀 containment group。reader 发现未换行输出超过上限时必须先原子投影 `output-limit-exceeded` 并唤醒 poll,再异步发送终止控制,不能让高负载下的 supervisor 调度延迟把已越界进程继续暴露为 `running`。
- 验证:使用直接 bash target 启动同组后台子进程;leader 在输出 READY 后自然退出,仍存活的子进程收到 TERM 后由 trap 延迟 400ms 写 marker 并退出,terminate 返回前 marker 必须存在。正式 `command.exec` 测试夹具仍必须走允许的 `npm run` 等程序,不能为了构造 stdin race 绕过白名单直接解析 `bash -lc`。另跑 immediate stdin/EOF、Runner owner SIGKILL 和后代隔离用例,确认前台切组没有破坏交互或 fail-closed 回收;测试互斥锁在前序 panic 后应恢复 guard 继续报告后续独立结果,不能用 `PoisonError` 掩盖真实失败范围。
- 验证:使用直接 bash target 启动同组后台子进程;leader 打印 READY 后用 `wait` 保持存活直到 terminate 真正到达(leader 若自己先退出,客户端调度就被拖进 800ms 宽限窗口,见 2026-10-04「graceful terminate 断言」条),仍存活的子进程收到 TERM 后由 trap 延迟 400ms 写 marker 并退出,terminate 返回前 marker 必须存在。正式 `command.exec` 测试夹具仍必须走允许的 `npm run` 等程序,不能为了构造 stdin race 绕过白名单直接解析 `bash -lc`。另跑 immediate stdin/EOF、Runner owner SIGKILL 和后代隔离用例,确认前台切组没有破坏交互或 fail-closed 回收;测试互斥锁在前序 panic 后应恢复 guard 继续报告后续独立结果,不能用 `PoisonError` 掩盖真实失败范围。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/process_session.rs`、`process_session_bridge.rs`、`command_sandbox_trampoline.rs`。
## 启动记录必须封闭状态组合,child 不能自行猜 durable commit 超时
@@ -6314,3 +6314,29 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- **现行口径**:见 `development-workflow.md` 的「AGC 测试类型门禁」;tests 必须 0 error,不引入基线或豁免,只改类型层。
- **环境提示**:Node 24+ 默认启用实验性 Web Storage,全局 `localStorage` 未配置即 `undefined`,会顶掉 vitest 0.34 jsdom 环境里的 Storage,`recentProjectsHook.test.tsx`、`gameDistributionPublish.test.ts` 在 Node 26 上失败(HEAD 即如此)。项目按 `@types/node ^22.14` 面向 Node 22,本机用 fnm 装 v22.23.3 并设为 default(`~/.configure/profile.d/fnm.sh` 在 shell 启动时 `eval "$(fnm env)"`),Node 22 不暴露该全局、jsdom 的 localStorage 正常,仓库无需任何改动。不要用 `--localstorage-file=…` 绕:那只是把 Node 自己的文件型 Storage 顶上来,多个用例文件共享同一份状态。
- **关联**:`apps/ai-game-creator-shell/tsconfig.tests.json`、`apps/ai-game-creator-shell/package.json`、`apps/ai-game-creator-shell/tests/`。
## 2026-10-04 tracing 的 callsite interest 是进程级缓存:并发测试会把 span 调用点缓存成 never,span 看起来"根本没产生"
- **现象**:`app::tests::http_tracing::unavailable_router_rejection_keeps_generated_context_and_headers` 偶发 `each rejected request should have one HTTP span left: 0 / right: 1`(`server-rs/crates/api-server/src/app.rs`),同一族断言在 `server-rs/crates/platform-llm/src/observability_tests.rs` 偶发 `provider_spans.len() == 1` 失败。同一批代码时而绿时而红,且失败用例都是最早跑的一批。
- **原因**:`span!`/`info_span!` 宏在调用点缓存 interest 为 `never` 时**静默返回空 span**,连 `new_span` 都不会调用(tracing 0.1.44 `macros.rs` 的 `span!` 分支)。而 `DefaultCallsite` 的 interest **只在调用点首次被命中时算一次**,且计算时用 `DISPATCHERS.rebuilder()`——进程里只注册过一个 dispatcher 时它会退化成 `dispatcher::get_default()`,即**命中线程自己的 dispatcher**(tracing-core 0.1.36 `callsite.rs` 的 `Rebuilder::JustOne`)。libtest 默认并发跑同一二进制里的上千个用例,没有 subscriber 的测试线程一旦抢到 `http.request` / `llm.request` 调用点的首次注册,就会把它永久缓存成 `never`。`with_subscriber` 只在**每次 poll** 设线程本地 dispatcher,纠正不了这个进程级缓存,于是"span 没产生"。
- **处理(现行口径)**:测试采集不要依赖 `with_subscriber`。改为在整个被测流程期间持有 scoped default(`tracing::subscriber::set_default`,其内部 `Dispatch::new` 会触发 tracing 重建 interest 缓存),并用同一调用点预热探测到连续两轮采集成功为止;测试 subscriber 显式实现 `register_callsite`(目标 span 恒 `always`、其余 `sometimes`),避免自己的重建把其它调用点永久标记成 `never`。**不要**把断言改成"允许 0 个 span",**不要** sleep 赌时序。
- **验证**:`cargo test --locked -p api-server --bin api-server app::tests::http_tracing`(默认并发与 `--test-threads=1` 各连跑 20 次)、`cargo test -p platform-llm observability_tests`;更接近 CI 并发的是整段 `app::tests::`(91 用例同进程)与 `--skip bgfilter_worker --skip wallet_refund_outbox` 的全量 bin(1133 用例)连跑。
- **关联**:`server-rs/crates/api-server/src/app.rs`、`server-rs/crates/platform-llm/src/observability_tests.rs`。
## 2026-10-04 AGC 通知计数与 graceful terminate 的断言偶发都来自"跨线程 / 跨用例串台"
- **现象**:`agent::thread_manager::tests::active_turn_changes_publish_one_notification_per_real_change` 偶发 `left: 8 / right: 7`(进度内容变化必须通知一次);`process_session::tests::process_session_graceful_terminate_keeps_wrapper_alive_for_target_cleanup` 偶发 `left: "exited" / right: "terminated"`;两者都在 `AI game creator shell Rust lane 2/2` 分片里红。
- **原因 1(通知计数串台)**:测试计数器 `DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT` 在 *2026-10-01 已按线程作用域隔离*(`thread_local! Cell`),但 2026-10-02 退役 `runtime_driver` 把这段接缝搬进 `agent/direct_events.rs` 时**降级回进程级 `static AtomicU64`**。`--test-threads=1` 只串行测试线程,宿主 `tauri::async_runtime` 的后台回合仍在自己的工作线程上广播「运行中的项目」变了,于是断言取到别的回合的广播。
- **原因 2(terminate 竞速)**:测试命令里 leader 打印 READY 后立刻 `exit 0`,同组后代仍存活,trampoline 从 leader 被回收那一刻开始 `PROCESS_SESSION_TARGET_TERMINATE_GRACE_MS=800ms` 宽限;客户端只要在 leader 退出后 >800ms 才发出 terminate(CI 高负载下要跨 durable record 写盘、registry 注册、线程 spawn),会话已按 `exited` 收口,terminate 只能读到既成事实——不是产品缺陷,是测试赌了客户端调度。
- **处理(现行口径)**:①测试专用的通知计数必须留在测试线程作用域(`thread_local! Cell`),不要用进程级 Atomic;②graceful terminate 用例的 leader 打印 READY 后要用 `wait` 等后台子进程,让 terminate 必然落在会话仍 running 时(断言、trap、`sleep 0.4`、marker 名字都不改)。
- **验证**:①修复前把计数器临时改回 Atomic 时同一并行口径 42/50 红;修复后并行 50 次 0 红、`--test-threads=1` 200 次 0 红、CI 现场等价块(145 用例)3 次 0 红;②该用例是 `#[cfg(target_os = "linux")]`,Windows 本机跑不到,用真实 Linux 内核(WSL Alpine)验证命令形状:leader 活到 TERM、同组后代完成 400ms 延迟清理(marker=done,real 0.41s)、清理后组内零残留;CI 侧仍应跑 `node apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs --shards=4 --shard-index=4` 复核。
- **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/direct_events.rs`、`apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs`、`command_sandbox_trampoline.rs`。
## 2026-10-04 SPA 深链的前缀路由(`/pay/<checkoutToken>`)必须进 allowlist,裸前缀不够
- **现象**:只把 `/pay`、`/profile/payment` 加进 Nginx SPA allowlist 让门禁变绿,并不代表真实收银台链接能打开。`payment.rs` 生成的 `checkoutUrl` 是 `/pay/<checkoutToken>`,三份模板原先只有 `location ~* "^/(?:…|pay|profile|profile/payment|…)/?$"` 这条精确 location,深链落回默认 `location /` 的 `try_files $uri $uri/ =404` → **404**。
- **原因/代价**:前端 `resolveSelectionStageFromPath` 用 `startsWith('/pay/')` 判定并取最后一个路径段当 token,Nginx / Pingora 侧却只放行裸前缀(2026-10-03 的支付接入 commit 只改了前端路由源)。同一批漂移里还有一条被掩盖的失败:`check:nginx-spa-routes` 在 `npm run lint` 链里先跑,它红的时候看不到后面的 `check:pingora-route-parity` 也红(Pingora `MAIN_SPA_PATHS` 缺 `/pay`、`/profile/payment`)——修一条门禁时要把整条链跑到底,不要只看第一个红。
- **处理(现行口径)**:前缀路由的真相源是 `src/routing/activeAppPageRoutes.ts` 的 `APP_PREFIX_ROUTE_ENTRIES`。`scripts/check-nginx-spa-routes.mjs` 据此要求三份模板都写锚定前缀 location(`location ~* "^/pay/[^/]+/?$"`,只放行「前缀 + 恰好一个路径段」,裸前缀仍由精确 location 负责,并要求镜像精确 location 的维护闸);`check:pingora-route-parity` 要求 Rust 的 `MAIN_SPA_PREFIX_PATHS` 与 `is_main_spa_prefix_path` 同口径(大小写不敏感、多段与 `/payment/x` 这类同名邻居不收)。
- **别踩**:不要写成裸前缀正则(`^/pay`)——它会吞掉 `/payment/x`、`/paycheckout/x` 这类同名邻居;也不要把深链塞进精确 allowlist 的 alternatives 里(`pay` 的 alternatives 只匹配 `/pay`)。
- **判据/取证**:`node --test scripts/check-nginx-spa-routes.test.mjs`(正/反用例,含「写回精确匹配即红」)、`npm run check:nginx-spa-routes`、`npm run check:pingora-route-parity`、`cargo test -p pingora-gateway -- pay_checkout_deep_link matches_nginx_route_parity_matrix`;线上复验 `curl -s -o /dev/null -w '%{http_code}' https://<平台域名>/pay/<checkoutToken>` → 200 且正文与 `/` 同一份 `index.html`。
- **关联**:`scripts/check-nginx-spa-routes.mjs`、`deploy/pingora/nginx-route-parity.matrix.json`、`server-rs/crates/pingora-gateway/src/main.rs`、`server-rs/crates/api-server/src/payment.rs`、`deploy/nginx/genarrative.conf`。
@@ -63,7 +63,7 @@ npm run check:pingora-release-readiness
`check:pingora-gateway-smoke` 会临时启动 mock `api-server`、mock SpacetimeDB、mock Gitea 和 `pingora-gateway`,覆盖精确主站 SPA fallback、大小写与尾部斜杠兼容、同前缀未知路径真实 404、后台静态路由、HTML / 普通静态资源 `no-cache`、Vite 指纹静态资源 immutable 缓存、静态 `ETag` / `Last-Modified` 与 `304` 协商缓存、静态 `HEAD` 响应、静态 Range、静态 access log method/path/status 对账、gzip 最小长度、小响应不压缩、图片资源不压缩、大响应压缩、ACME、TLS 直连、HTTP/2 ALPN、HTTP 到 HTTPS 重定向、内部路由拒绝、shadow probe、API 代理头(`Host` / `X-Forwarded-Host` / `X-Forwarded-Proto` / `X-Real-IP` / `X-Forwarded-For`)、Gitea Host 整站转发、请求体上限、429 接流保护、上游断连 / 超时 JSON 错误、维护模式、维护模式不拦截 Gitea Host 和 SpacetimeDB WebSocket Upgrade,并复用 `check-pingora-direct-live.mjs` 对临时 HTTPS / HTTP redirect / WSS subscribe 入口做 live smoke。该本地 fixture 会让首页同时引用普通静态资源和 Vite 指纹静态资源,direct live JSON 必须确认指纹资源 GET / HEAD / `Range: bytes=0-0` 以及 access log method/path/status 证据,避免正式直连前只证明普通静态读取。排查失败时可追加 `-- --verbose` 输出网关 stderr / stdout;已确认二进制无需重编时可追加 `-- --skip-build`。
`check:nginx-spa-routes` 从 `appPageRoutes.ts` 的 `STAGE_ROUTE_ENTRIES` / `APP_RUNTIME_ROUTES`、`appRoutes.tsx` 的精确路由判断和兼容恢复路径 `/creation/rpg/agent` 提取当前主站 SPA allowlist,确认生产、开发和容器三套 Nginx 模板集合一致,并验证大小写、尾部斜杠和 `/creation/not-exist`、`/runtime/not-exist`、`/puzzle/not-exist` 等未知反例。
`check:nginx-spa-routes` 从 `appPageRoutes.ts` 的 `STAGE_ROUTE_ENTRIES` 与 `APP_PREFIX_ROUTE_ENTRIES`、`appRoutes.tsx` 的精确路由判断和兼容恢复路径 `/creation/rpg/agent` 提取当前主站 SPA allowlist,确认生产、开发和容器三套 Nginx 模板集合一致,并验证大小写、尾部斜杠、前缀路由的「前缀 + 恰好一个路径段」锚定形状(收银台深链 `/pay/<checkoutToken>` 必须整体回退 `index.html`,只放行裸前缀会让真实链接落到默认 location 变 404)和 `/creation/not-exist`、`/runtime/not-exist`、`/puzzle/not-exist` 等未知反例。该脚本自带正/反用例(`node --test scripts/check-nginx-spa-routes.test.mjs`,由 `npm run check:nginx-spa-routes` 一起执行),防止有人把前缀路由改回精确匹配。
`check:pingora-route-parity` 会先执行同一 Nginx SPA 路由门禁,再读取 `deploy/pingora/nginx-route-parity.matrix.json`,静态确认生产 / 开发 Nginx 模板、Pingora Rust 路由 allowlist / 单测和本文档都覆盖同一组核心路由,并做**反向覆盖**(模板里的每条 `location` 都必须被矩阵声明)。`cargo test -p pingora-gateway --manifest-path server-rs/Cargo.toml matches_nginx_route_parity_matrix` 会读取同一份矩阵,逐条断言 `classify_path` 的路由结果、body limit 和接流保护分组。`check:nginx-spa-routes` 与 `check:pingora-route-parity` 已串进 `npm run lint`(因此 `check:repository-ci`、CI 与 pre-push 都会执行),接线本身由 `check:production-ops` 的 guardrail 锁定。
@@ -535,7 +535,8 @@ dev 根盘空间在安装后曾接近满盘;2026-06-17 进入 canary 前已清
| `/v1/database/{db}/subscribe`、`/v1/identity*` | 转发到 SpacetimeDB,保留 WebSocket Upgrade 头。 |
| `/__genarrative_pingora/healthz` | 仅在携带 `X-Genarrative-Pingora-Probe` 且匹配配置 token 时返回 shadow JSON,否则 404。 |
| `/v1/*`、`/generated-*`、`/healthz*`、`/readyz*` | 返回 404,保持生产公网不暴露口径。 |
| 主站 SPA allowlist | 只对 `/`、`/components`、`/creation`、`/design-system`、`/editor/canvas`、`/games`、`/games/detail`、`/games/mine`、`/games/play`、`/games/publish`、`/profile`、`/project` 失败回退 `/index.html`(集合与前端路由源、Nginx 三份模板逐条一致,由 `npm run check:pingora-route-parity` 与 `cargo test -p pingora-gateway matches_nginx_route_parity_matrix` 比对);匹配大小写不敏感并允许一个尾部斜杠,HTML 默认 `no-cache`。`/games/game_<32 位十六进制 id>/…` 是发行网关路由,不在 SPA allowlist 内。 |
| 主站 SPA allowlist | 只对 `/`、`/components`、`/creation`、`/design-system`、`/editor/canvas`、`/games`、`/games/detail`、`/games/mine`、`/games/play`、`/games/publish`、`/pay`、`/profile`、`/profile/payment`、`/project` 失败回退 `/index.html`(集合与前端路由源、Nginx 三份模板逐条一致,由 `npm run check:pingora-route-parity` 与 `cargo test -p pingora-gateway matches_nginx_route_parity_matrix` 比对);匹配大小写不敏感并允许一个尾部斜杠,HTML 默认 `no-cache`。`/games/game_<32 位十六进制 id>/…` 是发行网关路由,不在 SPA allowlist 内。 |
| 主站 SPA 前缀路由 | 收银台深链 `/pay/<checkoutToken>` 走 `MAIN_SPA_PREFIX_PATHS`:只放行「前缀 + 恰好一个路径段」(大小写不敏感),裸前缀由上面的精确集合负责,多段路径与 `/payment/x` 这类前缀同名邻居都不进 SPA fallback;Nginx 三份模板同口径写成 `location ~* "^/pay/[^/]+/?$"`,由矩阵的 `pay_checkout_spa_fallback` 用例固定。 |
| 其它 Web 路径 | 只读取真实静态文件或目录 index,缺失时返回真实 404;`/creation/not-exist`、`/runtime/not-exist`、`/puzzle/not-exist` 不进入 SPA fallback。 |
SPA allowlist 里属于游戏分发入口的深链(游戏目录 / 详情 / 游玩 / 我的 / 发布深链:`/games`、`/games/detail`、`/games/play`、`/games/mine`、`/games/publish`)与 Nginx 三份模板同口径;Pingora 侧由路由对照矩阵的 `games_spa_fallback` 用例与 `cargo test -p pingora-gateway matches_nginx_route_parity_matrix` 逐条断言。根路径 `/` 精确回退 `/index.html`(Nginx 在 `location = /` 里用 `try_files /index.html =404;`,不带 `$uri`),由矩阵的 `web_root_spa` 用例固定。发行网关路径 `/games/game_<32 位十六进制 id>/…` 不走 SPA,见下一节的对照说明。
@@ -748,10 +748,12 @@ Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分
- 门禁:
```bash
# SPA 白名单 + 三份 nginx 模板一致性(含 games 系列路由)
# SPA 白名单 + 三份 nginx 模板一致性(含 games 系列路由与收银台深链前缀路由)
npm run check:nginx-spa-routes
```
线上/预发复验收银台深链时,除了 `npm run check:nginx-spa-routes`,还要用真实请求确认 `/pay/<checkoutToken>` 返回 SPA 外壳而不是 404(`curl -s -o /dev/null -w '%{http_code}' https://<平台域名>/pay/<checkoutToken>` 应为 200,正文与 `/` 同一份 `index.html`)。`payment.rs` 生成的 `checkoutUrl` 就是这个路径,只把 `/pay` 加进 allowlist 会让真实链接落到默认 location 变 404。
本地想在真实边缘语义下复验时,把 `deploy/nginx/genarrative.conf` 的证书路径与 `/var/log/nginx` 换成临时目录,用 `nginx -c <临时 wrapper>` 起一个临时实例,再用 `curl --resolve <平台域名>:443:127.0.0.1 https://<平台域名>/games/<gameId>/` 验证:入口文档 200 `text/html`、`/games/<gameId>/assets/*` 200、未知 gameId 404,平台 API 与 SPA 路由不受影响。
#### 游戏分发可观测事件