Files
Genarrative/docs/project-memory/shared-memory/pitfalls.md
T
k88936 0529e07d9f
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Merge remote-tracking branch 'origin/master' into style/polish-taonier-publish
2026-10-08 17:06:27 +08:00

656 KiB
Raw Blame History

踩坑与排障记录

这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。

主题目录

维护约定

  • 只收录能避免可复发误判、解释不直观机制或显著缩短排障的经验。普通缺陷修复由代码、测试和 Git 历史保存;功能规格、配置步骤与架构约束放现有权威专题,确认承接后从本文件移出。是否已修复、是否采用“现象—原因—处理”格式,不是保留依据。
  • 新增前先搜索同类经验;已有条目能承接时补充原条目,避免重复记录。
  • 按主要根因归入下列主题,每条只保留一个位置;跨主题需要关联时使用链接。工程构建、部署和客户端更新归“构建、打包与发布”,作品发布等业务链路归“业务功能与第三方集成”。
  • 暂无明确归属或涉及跨领域协作的内容放“跨领域与待归类”;形成稳定主题后再评估拆分、合并或改名。
  • 主题使用二级标题,经验使用三级标题,条目内部的小节使用四级标题;标题尽量保持稳定,便于链接。

记录格式

### 问题标题

- 现象:看到什么错误或异常行为
- 原因:确认后的根因
- 处理:具体修复步骤
- 验证:如何确认修复有效
- 关联:相关文件、文档、提交或 Issue

开发环境与验证

2026-10-05 预览桥脚本是编译期内嵌的:改完必须重启 AGC 客户端

  • 现象:改了桥脚本(运行画面点选 / 尺寸上报逻辑),Vite HMR 与刷新预览页都不换——预览页仍跑旧桥,新加的判定与兜底完全不生效。
  • 原因:apps/ai-game-creator-shell/src-tauri/resources/preview/local-preview-fit.js 由 preview.rs 用 include_str! 编译期编进客户端 exe,预览服务器 /__genarrative/local-preview-fit.js 返回的就是 exe 里那份常量,重读磁盘不会发生。
  • 结论(现行口径):改桥后必须重启 AGC 客户端才生效——先确认没有在跑的 AGC Vite(3080 等端口空闲)再 npm run agc;只刷新页面、只重启后端或只重装 npm 依赖都无效。核实内嵌版本:在 exe 二进制里搜新代码标记,或比对 resources/preview/local-preview-fit.js 的 sha256。
  • 关联:apps/ai-game-creator-shell/src-tauri/src/preview.rs(PREVIEW_FIT_BRIDGE_SCRIPT)、apps/ai-game-creator-shell/src-tauri/resources/preview/local-preview-fit.js、genarrative-dev-stack-port-routing(端口探测与 npm run agc 口径)。

工作树路径会被 Vite 无条件 worktree 忽略规则整棵排除

  • 根因:根 vite.config.ts 与 apps/admin-web/vite.config.ts 无条件忽略 **/.worktrees/**;当前应用本身位于 .worktrees/<name>/ 时,所有源码被排除出 watch,修改不触发 HMR,重启不能改变规则。
  • 处理边界:忽略其它工作树时必须放行当前应用 root;不能用包含当前 root 的宽 glob。AGC Vite 不含这条规则,不能从 AGC 热更正常推断主站与后台正常。
  • 验证:检查生效的 watch 忽略集合,并实际修改主站/后台源码,分别确认 HMR 即时更新。
  • 关联:vite.config.ts、apps/admin-web/vite.config.ts、apps/ai-game-creator-shell/vite.config.ts、scripts/vite-cli.mjs。

2026-10-03 同一工作树并发拉起多份 dev 栈:.app/dev-stack.json 互相覆盖,启动兜底清扫会反杀健康栈

  • 现象:在同一工作树里再开一个 npm run dev 之后,AGC 侧报「后端归属校验失败」,或前端代理连到别的端口(「后端端口记错」);更严重的是新会话启动后,8082 / 8083 上原本健康的后端被清掉,旧会话随即报连接失败。
  • 原因:.app/dev-stack.json 是全工作树单文件(scripts/dev.mjs 的 resolveDevStackStatePath() → <repoRoot>/.app/dev-stack.json,scripts/dev-all.mjs 与若干 e2e 脚本也读它),每个 DevRunner 都整份覆写快照,端口、SpacetimeDB data-dir 与 instance id 只保留最后写入者,于是两份并发栈互相覆盖实例信息。同时 dev.mjs 在启动/退出时会按身份兜底清扫 stopWindowsWorktreeBackendProcesses(api-server.exe 绝对路径 + SpacetimeDB --data-dir),这是按工作树而不是按会话匹配的:其它会话留下的半死栈一旦重启,就会把当前健康栈一并收走。
  • 处理(现行口径):同一工作树保持单栈;确需并发时用显式端口参数(--api-port / --web-port / --admin-web-port / --spacetime-port 等)错开,并接受状态文件只有一个「最后写入者」。清理残留必须按端口 → PID → 命令行确认归属,再杀该 PID 的整棵进程树;不要 taskkill /IM node.exe(会误伤其它会话与 IDE 的 Node 进程)。
  • 排查顺序:先比对 .app/dev-stack.json 的 status / 端口与实际监听(Get-NetTCPConnection -State Listen -LocalPort ...)是否一致,再用 Get-CimInstance Win32_Process 按本工作树 server-rs\target\debug\api-server.exe 路径与 SpacetimeDB --data-dir 核对归属;不要因为 /healthz 返回 200 就认定后端属于当前会话。
  • 关联:scripts/dev.mjs(resolveDevStackStatePath / stopWindowsWorktreeBackendProcesses)、scripts/dev-windows-process.mjs、scripts/dev-all.mjs、scripts/check-game-distribution-ratings-e2e.mjs;另见本文件「npm run agc 的 Ctrl+C 不能只依赖 shell 包装层与端口健康检查」与 docs/【开发运维】本地开发验证与生产运维-2026-05-15.md。

2026-10-03 Windows 上会弹阻塞模态的启动失败用例挂死本机分片 runner

  • 现象:Windows 本机 npm run ai-game-creator-shell:check:rust:shell -- --shard-index=3/4 长时间无进展(可到 2400s 超时);单独跑那条用例同样超时——进程还在、CPU 不再增长、也没有子进程,形态很像「测试死锁」或「分片器坏了」。
  • 原因:apps/ai-game-creator-shell/src-tauri/src/main.rs:2042 的 startup_log_slot_fail_without_path_still_reports_instead_of_going_silent 调 StartupLogSlot::fail()(约 1854 行),而 fail() 会走 show_startup_error_dialog();Windows 实现(约 1740 行)用的是 MessageBoxW(..., MB_OK | MB_ICONERROR | MB_SETFOREGROUND),是阻塞模态,没有人点「确定」就永不返回。STARTUP_ERROR_DIALOG_SHOWN(约 1534 行)只在同一个进程内保证「只弹一次」,对测试用例没有任何豁免。分片 runner 用 --exact <名单> --test-threads=1 串行执行,一条挂死就整片挂死。
  • 影响面:Linux CI 走非 Windows 分支(约 1778 行)只写 stderr,不受影响;这是本机专属现象,不要据此判定 Rust 代码或分片规则有问题。
  • 处理(本机绕过):改用等价分块跑,而不是整片上阵——同一个测试二进制、同一 --exact <名单> 与 --test-threads=1 argv、同一 TMPDIR 隔离,把这条阻塞用例排除或单独限定。
  • 判据/取证:单独执行 cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell -- --exact startup_log_slot_fail_without_path_still_reports_instead_of_going_silent --test-threads=1 本机同样挂住;对照非 Windows 分支只产生 stderr 文案。
  • 关联:apps/ai-game-creator-shell/src-tauri/src/main.rs、apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs;另见本文件「同一 CI 容器内并行 Rust 分片可能比串行更慢」条。

2026-10-01 Windows dev:all 启动 npm 子进程报 spawn EINVAL

  • 现象:npm run dev:all 能完成 AGC Vite 端口预留,但在启动根开发栈前输出 spawn EINVAL。
  • 原因:Windows 的 npm.cmd 是批处理入口;Node child_process.spawn('npm.cmd', args, { shell: false }) 会直接返回 EINVAL,还未执行根 npm run dev。
  • 处理:scripts/dev-all.mjs 在 Windows 使用 shell: true、windowsHide: true 启动 npm 子进程;POSIX 仍使用独立进程组,退出时按进程组收束。
  • 验证:Windows 实测根开发栈已启动并完成端口漂移(Web 3001、API 8084、worker 8085、SpacetimeDB 3104、后台 3105),之后 AGC 因当前工作区缺少 @anthropic-ai/claude-agent-sdk 退出;dev:all 已收束根栈进程。

Jenkins 渠道环境必须与构建测试夹具隔离

  • 症状与根因:Jenkins 的 AGC_UPDATE_CHANNEL=release 会进入 Node 测试进程;resolveReleaseContext() 默认读取进程环境,导致默认渠道用例期望 dev 却得到 release,或 dev-only 的 bundle.windows.nsis.installerHooks 断言因 config.bundle.windows 不存在而报错。macOS 只是测试宿主,用例仍可能覆盖 Windows target,不能按宿主平台排查。
  • 处理:断言指定渠道配置时向 resolveReleaseContext(args, env) 显式传入该渠道;断言默认渠道、默认目标时用现有 withEnv 清除相关变量。生产入口继续读取渠道参数,不为测试改变发布逻辑。
  • 验证:build-release.test.mjs 与 cargo-features.test.mjs 在设置 AGC_UPDATE_CHANNEL=release 和未设置变量两种环境下都必须通过。入口位于 apps/ai-game-creator-shell/scripts/,Jenkins 调用方为 jenkins/Jenkinsfile.ai-game-creator-shell-macos-build。

Rust 同步回调的测试记录按线程隔离

  • shared-contracts 的资源 kind reporter 是进程级回调。仅给注册和断言加锁,无法阻止其他并行 manifest 测试触发该回调,导致日志数量和内容断言偶发混入其他测试记录。
  • kind 解析与回调在调用线程同步执行,测试收集器使用线程局部存储,各用例开始时清空本线程记录;生产 reporter 保持不变。保留完整记录断言,并用两个线程分别解析和核对记录,验证隔离;不要通过全局串行测试或放宽断言掩盖干扰。

2026-09-17 ts-rs 生成物换目录后,忘记同步忽略规则会让「生成物抖动」假装成代码改动

  • 现象:GameCreationAppAssetKind 的 ts-rs export_to 从 apps/ai-game-creator-shell/src/contracts/generated/ 换到 packages/shared/src/contracts/generated/ 后,任何 cargo build / cargo test 都会重写生成文件;若新目录没进 .prettierignore 与 .eslintrc.cjs 的 ignorePatterns,lint-staged / prettier 会把生成物重新格式化,于是每次提交都出现「生成物被改」,cargo test export_bindings 也不再幂等(跑完 git diff 不为空)。
  • 处理(现行口径):生成目录一律成对登记 .prettierignore + eslint ignorePatterns;改 export_to 时同步改这两处,并用 cargo test --locked -p shared-contracts --features ts-bindings export_bindings --manifest-path server-rs/Cargo.toml 后 git diff 为空来验证幂等。
  • 易错点:旧的 apps/ai-game-creator-shell/src/contracts/generated/ 目录下的同名文件不会自动删除,换目录后必须显式删除旧文件,否则会出现「两个同名 union,改动只落在一个目录」的假绿。

AGC Windows 开发态首次页面加载缓慢

Vite 默认监听应用根下的 Rust src-tauri/target,构建产物较多时会创建大量 Windows 文件监听器。AGC 配置通过 server.watch.ignored: ['**/src-tauri/target/**'] 排除此目录,不关闭业务源码、CSS、共享组件监听或 HMR。排查时区分后端就绪、Vite 扫描和原生窗口首绘;监听目录回归不能代替实机首绘测量,验证入口见本地开发运维文档。

2026-09-17 从提权会话创建的目录会被 AGC 的 Windows owner 校验直接拒绝

  • 现象:在 Codex 会话里手工创建的工程目录(例如 C:\Users\<user>\Documents\Codex\...\cocos-preview-fixture),用客户端打开时报 Windows 安全对象不属于当前用户:<path>;Rust 侧同样用 tempfile::tempdir() 建夹具的用例也成片失败在同一句上。
  • 原因:这个 shell 以管理员身份运行,New-Item / tempfile 新建目录的 owner 是 BUILTIN\Administrators,而 AGC 的校验要求 owner 等于当前用户 SID(KDLETTERS\<user>)。Get-Acl <path> | Select Owner 与 whoami 一比就能定性;同一台机器上由客户端自己创建的目录 owner 正确,所以「客户端自己建的项目能用、手建的不能用」。
  • 处理:手工夹具先 icacls <path> /setowner "<DOMAIN>\<user>" /T;Rust 用例改用工程自带的 crate::tests::canonical_test_tempdir(prefix)(它会 canonicalize 并重置目录 owner),不要直接用 tempfile::tempdir()。判「用例失败与本改动无关」时,先确认失败信息是不是这一条。

2026-09-16 从 Codex 里启动 AGC 客户端会看到被重定向的 %APPDATA%

  • 现象:在 Codex 会话里用 Start-Process 启动 genarrative-ai-game-creator-shell.exe 做排障时,子进程写 C:\Users\<user>\AppData\Roaming\world.genarrative.ai-game-creator\... 的内容会落到 C:\Users\<user>\AppData\Local\Packages\OpenAI.Codex_2p2nqsd0c76g0\LocalCache\Roaming\...;同一个 Test-Path / Get-ChildItem 命中的是重定向视图,只有 \\?\C:\Users\... 形式能区分真实路径。
  • 影响:用 agent 拉起的客户端复现「多开 / 登录态冲突 / 锁文件被占用」类问题时,可能与用户双击开始菜单快捷方式的真实进程不是同一份 AppData,从而得出「两个实例没有互相冲突」或「日志里没有那条失败」的错误结论。
  • 处理:把用户双击快捷方式(或从 Codex 之外启动)的进程作为唯一用户侧证据;核对待查文件时同时比对真实 AppData\Roaming 路径与 Packages\...\LocalCache\Roaming 路径;排障结论里写明客户端是「谁启动的」。

挂载期新增 IPC 必须同步严格测试桩与返回契约

  • 根因:组件挂载期新增 IPC 时,严格白名单桩未登记会抛错或返回 undefined;后续数组处理产生 unhandled rejection,或既有错误提示导致不相关断言失败。
  • 处理:新增挂载期 IPC 后扫描所有渲染该组件的严格桩,登记合法命令并保持真实返回契约。list_local_project_asset_generations 返回数组,空账本为 [],不是 { tasks: [] };不要放宽 unexpectedCommands。生产 IPC 拒绝走既有提示路径,不留下 unhandled rejection。
  • 验证:测试通过数之外还必须确认零 unhandled error;提示断言失败时先查桩与异步错误。
  • 关联:apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx、apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts、src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts。

Windows 就绪轮询不要逐端口重复走 WMI

  • 根因:Windows 逐端口 Get-NetTCPConnection 与逐 PID Get-CimInstance 都走 WMI,慢调用被就绪轮询放大;服务已健康仍可能长期判归属未知。
  • 处理:用 netstat -ano -p tcp 取端口与 PID,以外部地址 0.0.0.0:0 / [::]:0 识别监听,避免依赖本地化 State,PID 取最后一列。进程名与可执行路径用 .NET Process 读取;只有核对 SpacetimeDB --data-dir 或路径兜底时取命令行,并按 PID 缓存、设置 TTL 限制 PID 复用误判。
  • 边界:新 PID 首次取命令行仍可能较慢。探测失败或归属无法证明时不得静默复用服务。
  • 关联:apps/ai-game-creator-shell/scripts/start-dev-stack.mjs 的 readWindowsPortOwnerIdentities、apps/ai-game-creator-shell/tests/start-dev-stack.test.ts。

2026-09-24 重复 #[test] 属性会让 Rust 分片门禁报「同一用例被分到两片」

  • 现象:node apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs 编译成功后立刻失败:shard split selected the same test more than once; the split rule must be disjoint。单独 cargo test <该用例名> 一切正常,cargo test 编译也不报错。
  • 原因:#[test] 被写了两次——apps/ai-game-creator-shell/src-tauri/src/workspace_preferences.rs 的 client_server_selection_is_normalized_and_validated。Rust 允许重复 #[test],同一个测试二进制里会把同一个名字注册两次;<test-bin> --list 因此列出两条同名条目,分片器的「片并集等于全集且互不重复」自检直接判失败。CI 的每个 Rust shard job 都会跑这条自检,所以这不是本地专属问题。
  • 处理:删掉多余的那一个 #[test]。排查方式:同一文件里连续两行都是 #[test](或 #[tokio::test]);全仓扫过,当时只有这一处。
  • 同一类限制(未改):Windows 上分片 runner 走到真正执行时会 spawn ENAMETOOLONG(734 个用例名拼成的 argv 超过 Windows 命令行长上限);直接运行测试二进制 --test-threads=1 在本机跑到约 20 分钟后长时间无进展(无子进程、CPU 不再增长)。完整 AGC Rust 套件仍以 Linux CI 的分片 job 为准,本地跑定向 filter。

2026-09-14 check:native-shells 的调用链扫描在 Windows 上恒假

  • 根因:Windows collectFiles 返回反斜杠路径,与 POSIX 写法的必需文件清单比较时成员判断恒假。normalizeModulePath 还会去掉 .ts/.tsx 后缀,不能用于文件身份比较。
  • 处理:扫描登记使用 normalizeScannedFilePath,只统一分隔符并保留后缀。
  • 环境边界:该步骤 runner 在 Windows 裸 spawnSync npm.cmd 会报 EINVAL;不能直接改成 shell: true 让含空格或中文的参数被重新解析。面向 systemd、Linux 文件模式或生产工具的部署门禁应在相应 Linux 宿主验证,Windows 定向检查不能冒充完整 CI。
  • 关联:scripts/check-native-shells.mjs 的 collectH5HostBridgeCallChainFiles / normalizeScannedFilePath。

2026-09-09 npm run agc 的 Ctrl+C 不能只依赖 shell 包装层与端口健康检查

  • 现象:npm run agc 按 Ctrl+C 后终端回到提示符,但上个工作树的 api-server.exe / SpacetimeDB 仍在监听 8082 / 8083 / 3101;切到另一个 worktree 再启动 AGC 时,前端仍然连到上个工作树的后端,在改过数据库 / schema 的工作树上会串库。
  • 原因:
    1. Windows 下所有长驻服务都由 Node shell: true 经 cmd.exe /d /s /c 包装层启动,Ctrl+C 会先杀掉包装层(退出码 0xC000013A)。scripts/dev.mjs 的 stopProcess 见到直接子进程已退出就直接 return,start-dev-stack.mjs / start-tauri-dev.mjs 对已退出 PID 的 taskkill /PID <pid> /T /F 只会失败并返回 stopped: false,于是更深的 cargo → api-server.exe 没有任何人收。
    2. 即使走到按根 PID 遍历进程树,遍历依赖快照里的父子链;中间层(包装层)先消失时链路断开,遍历只能拿到根 PID,深处的后端不可达。
    3. 复用判据只看 .app/dev-stack.json 的 status 与 /healthz、/readyz、/v1/ping,从不校验端口上的进程属于哪个工作树;残留后端照样“健康”,因此被当成自己的后端复用。
  • 处理:新增 scripts/dev-windows-process.mjs,同时提供按根 PID 遍历与按身份匹配(server-rs/target/debug/api-server.exe 绝对路径、SpacetimeDB --data-dir)两条独立清理路径。dev.mjs 在直接子进程已退出时也继续清理,并在退出时按身份兜底清扫本工作树后端(复用他人 standalone 时不清理)。start-dev-stack.mjs 在收到信号和 finally 各清扫一次本工作树 api-server.exe(仅限本次自己拉起后端的情况),复用前先校验端口监听进程归属,无法证明归属就不复用、改为启动自己的后端并允许端口漂移。
  • 排查顺序:先看 .app/dev-stack.json 的 status 与实际监听端口是否一致,再用 Get-CimInstance Win32_Process 按本工作树 server-rs\target\debug\api-server.exe 路径与 SpacetimeDB --data-dir 核对残留进程;不要因为 /healthz 返回 200 就认定后端属于当前工作树。
  • 验证:node --check scripts/dev.mjs scripts/dev-windows-process.mjs apps/ai-game-creator-shell/scripts/start-dev-stack.mjs;npx vitest run scripts/dev-windows-process.test.ts apps/ai-game-creator-shell/tests/start-dev-stack.test.ts scripts/dev.test.ts;真机确认 Ctrl+C 后没有匹配本工作树 api-server.exe 路径的残留进程。
  • 关联:scripts/dev.mjs、scripts/dev-windows-process.mjs、apps/ai-game-creator-shell/scripts/start-dev-stack.mjs、docs/【开发运维】本地开发验证与生产运维-2026-05-15.md。

委派幂等与 child 写入测试不能和真实后台 worker 抢状态

  • 现象:测试刚建立 static delivery,自动 parent-wake 就抢先恢复并终结父 Run,使随后同 action 重放被“当前 durable task 仍为 running”拒绝;受限美术 child 测试也可能在后台 worker 抢先终态后,让本应允许的 assets/** 写入误报 verification failure。
  • 原因:测试 fixture 同时手工推进 journal/manifest,又允许真实后台 future 执行同一父子 Run;单测运行时序决定谁最后写入。若为让测试通过而把 active durable task 门禁整体移动到 existing-child 分支之后,该分支仍可能补建 delivery 或投影 Ready,反而允许终态/过期父 Run 发生修复性写入。
  • 处理:保留生产门禁顺序,父 Run 终态后的迟到 delivery 继续只允许 suppressed。需要断言回执、claim 与同 action 重放时,测试持有父 Agent execution lane,断言结束后释放再执行 wake;直接测试 child 写工具时持有目标 Agent lane,再把 child 持久推进到 running。所有 lane 均由 RAII 释放,不能依赖后台 future 的调度时机。
  • 验证:覆盖父 Run active 时 claimed delivery 的同 action 重放返回 existing、不同 action 的重复缺口仍被拒绝、父终态后的新委派/迟到 delivery 继续失败关闭或 suppressed、合法运行中 child 只可写 assets/**。macOS 直接拼接 std::env::temp_dir() 的仓库安全测试还应先规范化临时根,避免 /var -> /private/var 被误当成项目内符号链接。
  • 关联:apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs、apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs、apps/ai-game-creator-shell/src-tauri/src/repository_context.rs。

Linux 生产脚本门禁不能假设本地也是 GNU userland

  • 现象:macOS 本地运行维护页、生产 API 部署和 Rust 产物门禁时,依次出现 mv: illegal option -- T、mapfile: command not found、/usr/bin/cp / /usr/bin/chmod 不存在,以及 .rlib 明明含有 .o 却报告“没有可扫描成员”;安全修复计划还会把 /var/folders 到 /private/var/folders 的系统别名误判为用户符号链接。
  • 原因:生产机是 Linux/GNU,而本地门禁运行在 BSD userland、Bash 3.2 和 BSD ar;测试桩硬编码 Linux 二进制路径与参数,归档解析器没有去掉 BSD 扩展成员名的尾随 NUL,路径校验也直接比较了未规范化字符串。
  • 处理:维护 marker 使用同目录临时文件加 POSIX mv -f,并在替换前拒绝所有符号链接和目录目标,避免 mv -f 跟随目录链接把临时文件移入链接目标;生产部署测试桩在 macOS 忠实模拟 GNU mv/ln -T 的“目标不是目录”语义,并按平台选择系统工具;脚本收集服务使用 Bash 3.2 可用的 while read;rlib 解析清理 BSD 成员名 NUL;计划文件只规范化系统临时目录别名,仍拒绝其下用户创建的符号链接组件。
  • 验证:运行 npm run check:maintenance-page、npm run check:production-api-deploy、npm run check:server-rs-ddd、npm run test -- scripts/spacetime-repair-editor-canvas-resources.test.ts,并在 Linux CI 保留同一生产脚本语义。
  • 关联:scripts/deploy/maintenance-on.sh、scripts/check-maintenance-page.mjs、scripts/check-production-api-deploy.mjs、scripts/deploy/production-api-deploy.sh、scripts/check-module-runtime-artifact.mjs、scripts/spacetime-repair-editor-canvas-resources.mjs。

CI root 环境不能用文件只读权限注入写失败

  • 现象:本地测试把 conversation 文件设为 readonly 后能稳定得到写入失败,Gitea Actions 中同一断言却发现写入成功并继续执行任务。
  • 原因:隔离 job 内测试进程可能以 root 运行;root 不受普通 owner write bit 的同等限制,set_readonly(true) 不是跨 runner 身份的确定性故障注入。
  • 处理:需要覆盖写失败恢复时使用仅在 cfg(test) 生效、一次性消费并限定写入阶段的 marker;生产路径仍走真实持久化函数。测试同时断言 marker 已消费、失败前数据未落盘和恢复后 exactly-once,不依赖 chmod、固定 sleep 或 runner 用户身份。
  • 验证:在普通本地用户和 root 容器中分别运行用户消息、assistant 最终回复持久化失败测试,均应进入相同 durable phase 并通过恢复断言。
  • 关联:apps/ai-game-creator-shell/src-tauri/src/project.rs、apps/ai-game-creator-shell/src-tauri/src/agent.rs、apps/ai-game-creator-shell/src-tauri/src/tests.rs。

PTY 测试不能假设输入回显与后续输出必然分行

  • 现象:PTY 环境隔离用例偶发得到 你好BRIDGE_ENV:,而不是独立的 你好 与 BRIDGE_ENV: 两行;真实私有环境变量并未泄漏,但整行相等断言失败。
  • 原因:canonical PTY 的输入回显和目标进程后续输出存在合法调度竞争,读取边界不等于逻辑行边界,回显可能与紧随其后的固定标记合并。
  • 处理:对不含秘密的固定标记按语义边界断言,例如要求某行以标记结尾;敏感值仍必须在完整 transcript 和公共持久面执行严格零命中扫描,不能借此放宽泄漏门禁。
  • 验证:process_session_pty_uses_private_environment_and_redacts_public_records 对 BRIDGE_ENV: 使用行尾匹配,并保留真实私有环境变量、stdin 正文与公共记录泄漏扫描。
  • 关联:apps/ai-game-creator-shell/src-tauri/src/process_session.rs、apps/ai-game-creator-shell/src-tauri/src/command_output.rs。

Rust 并行测试不要在 await 跨度内修改进程环境变量

  • 现象:单独运行的异步测试稳定通过,默认并行运行整个 crate 时却看到临时目录多出其它测试的文件、文件对被拆散,或目录清理与并发写入互相竞争;Gitea Backend CI 可能表现为日志数量断言偶发增加。
  • 原因:std::env::set_var / remove_var 修改整个测试进程,不属于当前 async task。测试在 await 前设置目录、结束后恢复时,同一 test binary 的其它用例会在中间窗口读取该值;只锁修改环境变量的测试也无效,除非所有间接读取方都参与同一把锁。
  • 处理:文件、队列、缓存等副作用目录进入实例配置,在构造时一次性解析环境默认值,并允许测试显式注入唯一临时目录。不要靠 --test-threads=1、固定 sleep 或只过滤自己的文件名掩盖错误路由;纯环境解析测试只有在全部相关读写都封闭于同一 OnceLock<Mutex<()>> 时才使用全局锁。
  • 验证:先精确运行目标用例,再以默认并行度重复运行完整 crate;失败类测试同时执行时,各实例目录只能包含自己的输入 / 输出日志,测试结束后临时目录必须清理。
  • 关联:server-rs/crates/platform-llm/src/lib.rs、server-rs/crates/api-server/src/creation_agent_llm_turn.rs、server-rs/crates/api-server/src/custom_world_foundation_draft.rs。

带 objectKey 的画布图片测试要等待换签后可见

  • 现象:测试点击“添加素材”后,图层状态已经写入,但立即用 getByAltText('画布图片:...') 偶发或稳定找不到图片;前一张图可能通过,紧接着添加的第二张失败。
  • 原因:带 objectKey 的画布图片通过 useResolvedAssetReadUrl 异步获取签名 URL,resolvedUrl 就绪前不会渲染带 alt 的 <img>。user.click 只等待点击交互完成,不等待 effect 内的换签 Promise;前一张图在后续操作期间出现只是调度时机,不是同步契约。
  • 处理:每次点击添加后分别用 await screen.findByAltText(...) 等待对应图片可见,再执行依赖该图层的下一步操作;不要用固定 sleep,也不要只等待最后一张图而让前面的断言依赖偶然调度。完整前端回归并行负载较高时,可只对明确跨越换签 Promise 的目标查询设置局部、有界的 5_000ms 超时,不要放宽 Testing Library 全局超时。
  • 验证:先精确运行目标用例并连续重复,再运行所在测试文件和完整前端测试;删除场景仍要保留 A/B 都消失、两个删除调用和撤销不恢复已删除素材的断言。
  • 关联:src/hooks/useResolvedAssetReadUrl.ts、src/components/image-editor/ImageCanvasWorldView.tsx、src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx。

Windows 本地角色动画抽帧找不到 ffmpeg 先查 dev 子进程环境

  • 现象:画板角色动画抽帧报 抽取动作视频帧失败:无法启动进程 ffmpeg:program not found(requestId:...),但新开的 PowerShell 里 ffmpeg -version 正常。
  • 原因:长期运行的 api-server 可能是在安装 FFmpeg 或更新用户 Path 之前启动的,子进程不会自动继承后续写入的用户环境变量。
  • 处理:Windows 本地默认把 FFmpeg 安装到 %LOCALAPPDATA%\Genarrative\ffmpeg\bin,并确保用户 Path 包含该目录;npm run dev / npm run dev:api-server 会在启动 api-server 时自动注入该目录和 CHARACTER_ANIMATION_FFMPEG_PATH / CHARACTER_ANIMATION_FFPROBE_PATH 绝对路径。修复后需要重启 api-server,不能只刷新浏览器。
  • 验证:where ffmpeg、where ffprobe 能找到本地安装;npm run test -- scripts/dev.test.ts -t "FFmpeg";重启 npm run dev:api-server 后访问 /healthz。
  • 关联:scripts/dev.mjs、server-rs/crates/api-server/src/config.rs、server-rs/crates/api-server/src/character_animation_assets.rs。

Windows 本地 dev 不要把 RUSTC_WRAPPER 绕过写成 rustc

  • 现象: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 脚本默认把两个 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。

本地旧 external-generation-worker 会抢队列并暴露成 procedure 超时

  • 现象:角色 / 画布生成的外部 provider 与 OSS 上传已成功,但 worker 写回 editor_project_resource 等业务资源时报 SpacetimeDB procedure 调用超时,日志里可能还能看到旧 worker 二进制对 procedure 返回值做 BSATN 反序列化失败。
  • 原因:本地 npm run dev / npm run dev:api-server 默认 GENARRATIVE_PROCESS_ROLE=all,会自己消费队列;如果之前手动启动的同仓库、同 database GENARRATIVE_PROCESS_ROLE=external-generation-worker 进程没有退出,旧二进制会继续 claim 新 job,schema / binding 已更新的当前进程反而没有拿到这次任务。
  • 处理:Linux 本地默认 all 角色启动前,scripts/dev.mjs 会扫描同仓库、同 SpacetimeDB server / database、同 server-rs/target/debug/api-server 的遗留 external-generation-worker 并停止;显式 GENARRATIVE_PROCESS_ROLE=api 做生产式拆分验证时不清理独立 worker。
  • 验证:ps -eo pid,ppid,lstart,cmd | rg 'server-rs/target/debug/api-server' 只应看到当前 all 或显式拆分下预期的进程;/healthz 和 /readyz 成功后,生成 job 应由当前进程消费并把业务资源写回。
  • 关联:scripts/dev.mjs、scripts/dev.test.ts、server-rs/crates/api-server/src/external_generation_worker.rs、docs/【开发运维】本地开发验证与生产运维-2026-05-15.md。

Linux 多用户 dev 端口冲突先查系统级端口段注册表

  • 现象:同一台 Linux 机器上多个用户同时开发时,npm run dev 报端口段已被其他用户占用、同一用户已有活跃端口段,或 SpacetimeDB 复用记录指向当前用户端口段之外的地址;未手动指定时自动分配应从 10000-10099 起步。
  • 原因:Linux dev 脚本会通过 /var/tmp/genarrative-dev-port-ranges/registry.json 做系统级端口段分配,避免两个用户配置相同或重叠端口段;同一用户后续启动会继续复用自己已经占用的固定端口段。注册表会保留该用户的段记录,不会因为多开而要求重新分配。
  • 处理:先确认当前用户已经占用的端口段,再让后续 npm run dev / dev:* 继续沿用这段;如确实要切换段,手动释放或清掉对应 registry 记录后再重启。需要临时隔离测试时用 GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR=<tmp-dir> 覆盖注册表目录。不要在 Windows 上按这个注册表排查,Windows 仍走原有端口探测与漂移逻辑。未指定端口段时,系统会从 10000-10099 开始顺序分配。
  • 验证:重新启动后终端应打印 [dev] port-range: <start-end> (<user>) 与 [dev] port-range-registry: .../registry.json;node node_modules/vitest/vitest.mjs run scripts/dev-stack-port-utils.test.ts scripts/dev.test.ts 应通过 Linux registry、自动分配 10000-10099 与 Windows bypass 用例。
  • 关联:scripts/dev-stack-port-utils.mjs、scripts/dev.mjs、docs/【开发运维】本地开发验证与生产运维-2026-05-15.md。

本地 SpacetimeDB procedure 超时或缺失先查版本错配

  • 现象:敲木鱼创作时点击“生成”提示 SpacetimeDB procedure 调用超时,或后台 Dashboard 的指标与柱状图同时消失;服务端日志更早出现 Failed to BSATN deserialize procedure return value、No such procedure,Dashboard 请求返回 502。
  • 原因:本机 spacetime CLI / standalone 版本与 server-rs/Cargo.toml 锁定的 spacetimedb 版本不一致时,procedure 返回值会在宿主侧反序列化失败,api-server 继续等待就表现成调用超时。若旧 worktree 已删除但其 orphan standalone 仍监听原端口,API 还可能连到旧 wasm:健康检查正常,新 bindings 对应的 procedure 却尚未发布。
  • 处理:先用 spacetime --version 和监听端口对应的 /proc/<pid>/exe --version 分别核对 CLI 与真实宿主,再和 server-rs/Cargo.toml 的锁定版本对齐;不能把新版本模块硬发布到旧宿主。旧实例仍有需要保留的本地数据时,先用迁移 procedure 导出,在独立端口启动匹配版本、发布当前模块并增量导入,逐表对账后再把本次 API 切到新实例;旧实例在对账前不停止。当前 dev 脚本会对带版本记录的本地实例校验 dev-spacetime-tool-version,但显式连接历史端口时仍要核对真实进程和 module schema。
  • 验证:CLI、standalone 与 Cargo 锁定版本一致,/v1/ping 正常,spacetime describe 可找到调用中的 procedure;Dashboard 接口返回 200 且包含 4 张图,敲木鱼生成不再卡在 procedure timeout。另执行 npm run test -- scripts/dev.test.ts 验证本地调度门禁。
  • 关联:scripts/dev.mjs、scripts/dev.test.ts、server-rs/Cargo.toml、docs/【开发运维】本地开发验证与生产运维-2026-05-15.md。

本地脚本调 VectorEngine 生图卡住先区分 fetch 首部超时

  • 现象:用 Node fetch 直接请求 POST /v1/images/generations,已经设置较长的 AbortController 超时,但仍在约 180 到 300 秒后抛 AbortError、TypeError: fetch failed 或 UND_ERR_HEADERS_TIMEOUT;同一 prompt 改用原生 https.request 可以在较短时间内成功返回图片。
  • 原因:Node/Undici 的默认 headers timeout 可能早于业务脚本期望的长生图等待窗口触发,表现上容易被误判成 VectorEngine 上游本身超时。
  • 处理:长期脚本优先复用后端 reqwest 或项目已有生成脚本;临时本地工具若必须用 Node,可改用原生 http/https.request 并显式设置 socket timeout,或为 Undici 单独配置 headers timeout。仍需隐藏 VECTOR_ENGINE_API_KEY,只报告配置是否存在。
  • 验证:同一 gpt-image-2 请求体、同一环境变量下,原生 HTTP 请求能返回 url / b64_json 并落盘;失败时错误里能区分请求发送、首部等待、下载和解码阶段。
  • 关联:.codex/skills/gpt-image-2-apimart/SKILL.md、server-rs/crates/api-server/src/openai_image_generation.rs。

本地 SpacetimeDB replica identity 不匹配

  • 现象:本地 standalone 启动时报 mismatched database identity。
  • 原因:本地 SpacetimeDB 数据目录中的 replica 数据残留与当前数据库身份不一致。
  • 处理:按本地 replica identity mismatch 文档进行备份、重建和脚本诊断。
  • 验证:本地 SpacetimeDB 可正常启动并 publish / 访问。
  • 关联:docs/technical/SPACETIMEDB_LOCAL_REPLICA_IDENTITY_MISMATCH_FIX_2026-04-30.md。

本地 SpacetimeDB publish 403 优先查 CLI 身份和目标库

  • 现象:spacetime publish 在 Pre-publish check 阶段返回 403 Forbidden,提示当前 identity 无权对目标 database identity 执行 update database。
  • 原因:当前 CLI 登录态不是目标数据库的创建者或授权身份,或 .env.local / publish 命令指向了另一个数据库或 SpacetimeDB 服务。
  • 处理:除 CI/CD 脚本内部受控用法外,不再使用 spacetime --root-dir 排障或发布。先执行 spacetime login show、spacetime server list,再用 spacetime list --server http://127.0.0.1:3101 或实际 --server-url 确认当前身份是否能看到目标库;本地开发发布优先使用 npm run dev:spacetime 或从 server-rs 目录执行显式 --server 的 spacetime publish。如果身份不对,重新登录正确身份、使用项目脚本重新生成本地库,或在 SpacetimeDB 侧补授权。
  • 验证:spacetime list --server http://127.0.0.1:3101 能看到目标库;重新发布不再使用无权限 identity。
  • 关联:scripts/dev.mjs、docs/technical/SPACETIMEDB_START_SH_PUBLISH_403_IDENTITY_FIX_2026-04-26.md。

本地 SpacetimeDB 权限失败先核实例与身份,清库只用于可丢弃测试数据

  • 根因:CLI 身份、默认 server、standalone 的实际数据目录与目标库不一致,可能在登录、发布或预检查返回 401 / 403;清理错误目录也不会修复目标。
  • 处理:先核对当前 dev 栈实际 server、数据库、数据目录和 CLI 身份,按本文件「publish 403 优先查 CLI 身份和目标库」走非破坏排查。只有确认是可丢弃本地测试库后,停止对应宿主并备份,再按仓库脚本重建实际数据目录中的测试库、重新登录到显式 local 目标并发布;不要清默认库来修复另一个实例,也不要把清库当通用启动修复。
  • 验证:发布指向核实过的 server/database,重建日志为创建新库;若仍更新旧库或返回权限错误,继续核对身份与 data dir,不扩大删除范围。
  • 关联:scripts/dev.mjs、docs/【开发运维】本地开发验证与生产运维-2026-05-15.md。

npm run dev -- --watch 前端无限重启先查外层 watcher

  • 现象:开启 npm run dev -- --watch 后,后台 Vite 或主站 Vite 反复退出重启,即使没有手动修改源码。
  • 原因:Vite 本身会监听源码并写入 node_modules/.vite 等缓存;外层调度器如果再递归监听前端目录并重启 dev server,就可能把 Vite 自己的缓存写入当成源码变化,形成循环重启。
  • 处理:外层 watcher 只负责后端侧:spacetime-module 改动后重新 publish,api-server 改动后重启 Rust 进程。主站 Vite 和后台 Vite 的源码变化交给 Vite HMR;需要进程级重启时在 npm run dev 终端手动输入 rs web 或 rs admin-web。
  • 验证:npm run dev -- --watch 下修改 apps/admin-web/src/** 应由 Vite HMR 处理,不应出现连续 [dev] 重启 admin-web;scripts/dev.test.ts 覆盖 web/admin-web 不注册外层 watch。
  • 关联:scripts/dev.mjs、docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md。

根目录 nohup.out 持续写入会触发主站 Vite 刷新循环

  • 现象:在仓库根目录用 nohup npm run dev ... & 启动完整 dev 栈后,即使没有修改前端源码,主站页面也会反复整页刷新;nohup.out 同时持续增长。
  • 原因:未显式重定向 stdout / stderr 时,nohup.out 会收集 SpacetimeDB、api-server 和两套 Vite 的整套 dev 栈输出。主站 Vite 的 root 是仓库根目录,若 watcher 未忽略这个持续写入的文件,每次追加日志都会被当成文件变化;后台 Vite root 是 apps/admin-web,仓库根日志不在其监听根内。
  • 处理:主站 vite.config.ts 的 server.watch.ignored 保持忽略 **/nohup.out,Git 同时忽略 nohup.out。修改配置后重启主站 Vite。若显式重定向到其它仓库内日志文件,该文件不会自动受保护,应写到 Vite root 之外或补充精确忽略规则。
  • 验证:在仓库根目录追加 nohup.out 时主站不再刷新,真实源码修改仍正常触发 HMR;git check-ignore nohup.out 能命中忽略规则,git status 不出现该日志。
  • 关联:vite.config.ts、.gitignore、docs/【开发运维】本地开发验证与生产运维-2026-05-15.md。

npm run dev:api-server 读取 env 的顺序必须让 .env.secrets.local 最后覆盖

  • 现象:POST /api/assets/hyper3d/text-to-model 在本地返回 503,详情里提示 HYPER3D_API_KEY 未配置,但开发者明明已经在本地私密文件里写了 key。
  • 原因:scripts/dev-utils.mjs 之前按 .env.secrets.local → .env.local → .env 合并,结果仓库里的 .env 空示例值会把前面已经设置好的私密 key 覆盖掉。
  • 处理:npm run dev:api-server / npm run dev:spacetime / npm run dev 统一按“外层 shell 变量优先,其后 .env、.env.local、.env.secrets.local 逐层覆盖”的顺序加载;真实密钥优先放 .env.secrets.local。本地认证开关例外:SMS_AUTH_ENABLED、SMS_AUTH_PROVIDER 等以本地 env 文件为准,避免父进程继承的旧开关值长期压过 .env.local。
  • 验证:本地加入临时测试后,HYPER3D_API_KEY 应能被 .env.secrets.local 覆盖,真实密钥 shell 变量仍然最高优先级;mergeApiServerEnv(..., { SMS_AUTH_ENABLED: "false" }) 在 .env.local 写 SMS_AUTH_ENABLED=true 时应返回 true。
  • 关联:scripts/dev-utils.mjs、server-rs/crates/api-server/src/hyper3d_generation.rs、docs/technical/HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md。

外层空环境变量会遮蔽本地 env 的真实配置

  • 症状与机制:本地 env 已配置 OSS,API 仍报未配置时,检查 shell / IDE 继承的 ALIYUN_OSS_* 是否为空。把空值当成最高优先级会跳过 env 文件中的真实值。
  • 处理:启动脚本只保护非空外层值,空字符串或全空白不得遮蔽本地 env。用 npm run check:api-server-env 查看键是否 present,再重启 API;检查和记录均不输出密钥值。

本地短信联调必须确认运行进程的 provider 与验证码来源

  • 现象与原因:发送成功却收不到短信,或 mock 验证码不被接受,先确认当前 api-server 的 SMS_AUTH_PROVIDER。mock 不发真实短信,aliyun 不接受 mock 码;修改 env 后未重启、旧 dev 进程继承外层环境都会让配置看似已改却未生效。提示“手机号登录暂未启用”时另查进程内 SMS_AUTH_ENABLED,cmd 的 set SMS_AUTH_ENABLED="true" 会把引号带入值并导致 bool 解析失败。
  • UI / 账号 smoke:显式设 SMS_AUTH_PROVIDER=mock 和 SMS_AUTH_MOCK_VERIFY_CODE,重启 npm run dev 或 npm run dev:api-server;发送接口应返回 providerRequestId=mock-request-id,使用配置中的验证码登录应返回 200 与 user.loginMethod=phone。
  • 真实短信:启用 SMS_AUTH_ENABLED=true、SMS_AUTH_PROVIDER=aliyun,确认 ALIYUN_SMS_ENDPOINT、签名、模板和参数键后重启。当前使用普通 SendSms,验证码由进程本地生成、哈希存储和校验;旧托管验证码接口参数不参与校验,重启会清掉未校验验证码。发送错误的 HTTP 映射另见“手机验证码登录 500 先查短信 provider 语义”。
  • 验证与入口:浏览器域名及 API 直连的 /api/auth/login-options 都应包含 phone/password;真实发送以日志 provider=aliyun 为准。配置真实短信测试所需凭据和目标号码后,按 docs/technical/PHONE_SMS_REAL_PROVIDER_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md 手动运行真实 provider 测试。配置实现见 server-rs/crates/api-server/src/config.rs、scripts/dev-utils.mjs。

Rust 冷编译导致 api-server 健康检查误超时

  • 现象:旧 npm run dev:rust 在 Windows 冷编译/链接阶段误判 /healthz 等待超时并杀掉 cargo run;现入口为 npm run dev 或 npm run dev:api-server。
  • 原因:脚本把 SpacetimeDB 与 api-server 等待窗口混在一起,未考虑 Rust 冷编译耗时。
  • 处理:按冷编译超时修复文档拆分等待窗口。
  • 验证:冷启动时不再误杀仍在编译的 api-server。
  • 关联:docs/technical/API_SERVER_DEV_STACK_COLD_BUILD_TIMEOUT_FIX_2026-04-25.md。

Windows debug api-server 主线程栈溢出

  • 现象:cargo check -p api-server 和 build_router 测试通过,但 npm run dev:api-server 在 Windows debug 启动时 thread 'main' has overflowed its stack。
  • 原因:api-server Axum 路由树已经很深,debug 主线程默认栈偏小,初始化状态和构造路由时容易触顶。
  • 处理:入口 main 用显式 16MB 栈线程启动 Tokio runtime,并把实际服务逻辑放入 run_server();新增路由时优先用小 router .merge(),避免继续拉长主链。
  • 验证:npm run dev:api-server 后 /healthz 返回 200,相关路由冒烟通过。
  • 关联:server-rs/crates/api-server/src/main.rs、server-rs/crates/api-server/src/app.rs。

Windows debug api-server.exe 锁文件与强杀退出码容易混淆

  • 现象:cargo run -p api-server 或 npm run dev:api-server 报 failed to remove file ... target\debug\api-server.exe;清理旧进程后,旧终端可能继续打印 process didn't exit successfully: server-rs\target\debug\api-server.exe (exit code: 0xffffffff)。
  • 原因:Windows 不能覆盖仍在运行的 exe;通常是上一条 npm run dev:api-server 链路仍在运行,进程树为 npm run dev:api-server -> node scripts/dev.mjs api-server -> cargo run -> api-server.exe。0xffffffff 常见于排障时用 Stop-Process -Force 强制结束旧 api-server.exe 后由 Cargo 回显,不一定代表新启动失败。
  • 处理:先按目标路径确认并停止本仓库的旧 api-server.exe 及其父级 cargo/node/cmd 启动链路,再重新启动;不要同时开多个 npm run dev:api-server。
  • 验证:确认没有匹配 C:\Genarrative\server-rs\target\debug\api-server.exe 的进程后,Remove-Item 能删除旧 exe;随后 npm run dev:api-server 启动并访问 /healthz 返回 200。
  • 关联:scripts/dev.mjs、server-rs/crates/api-server/src/main.rs。

dev scheduler 端口被旧进程占用时会误判健康检查

  • 现象:旧本地 dev 链路可能输出 Port 3000 is in use, trying another one...,随后 api-server.exe 报 AddrInUse / code: 10048。
  • 原因:旧 api-server 仍监听默认 8082 时,脚本的 /healthz 探测会命中旧进程并误判新服务已就绪;旧 Vite 占住 3000 时,Vite 默认漂移到新端口,浏览器仍可能打开旧页面。
  • 处理:scripts/dev.mjs 已在 publish / 编译前解析 SpacetimeDB、api-server、主站 Vite、后台 Vite 端口,并让 Vite 使用 --strictPort;遇到端口占用时会自动选择后续可用端口,也可显式传入 --api-port / --web-port / --admin-web-port。
  • 验证:默认端口被占用时,完整栈应打印 [dev:ports] ... 不可用,改用 ... 并把实际端口传给后续 publish、健康检查和 Vite 代理;清理端口后重新启动不再命中旧 /healthz。
  • 关联:scripts/dev.mjs、docs/technical/DEV_RUST_STACK_PORT_CONFLICT_PRECHECK_2026-05-09.md。

dev:spacetime 启动后 3101 又断开先查 publish 是否被 spacetime.json 干扰

  • 现象:浏览器报 Failed to initiate WebSocket connection,目标为 ws://127.0.0.1:3101/v1/database/<db>/subscribe,端口检查发现 3101 没有长期监听;手动运行 npm run dev:spacetime 可看到 standalone 短暂启动后退出,发布阶段报 No database target matches '<db>'。
  • 原因:SpacetimeDB CLI 会读取仓库根目录 spacetime.json。如果本地发布命令没有显式 --no-config,CLI 可能按配置文件里的 target 解析数据库,覆盖脚本已传入的 .env.local 数据库名和 --server,导致 publish 失败;dev.mjs 捕获错误后会清理刚启动的 standalone,于是浏览器看到 3101 被拒绝连接。
  • 处理:scripts/dev.mjs 的本地 publish 固定追加 --no-config,只使用脚本解析出的数据库名、module path 和实际 SpacetimeDB server。排查时前台运行 npm run dev:spacetime -- --no-interactive,若看到该错误,先确认脚本是否仍带 --no-config,再查 .env.local / spacetime.local.json 的数据库名。
  • 验证:npm run test -- scripts/dev.test.ts 覆盖 publish 参数包含 --no-config;npm run dev:spacetime -- --no-interactive 后 http://127.0.0.1:3101/v1/ping 应保持 200。
  • 关联:scripts/dev.mjs、scripts/dev.test.ts、docs/【开发运维】本地开发验证与生产运维-2026-05-15.md。

本地 api-server 启动订阅 401 先查 Web identity token 注入

  • 现象:npm run dev 启动到 api-server 恢复认证投影时,日志出现 Failed to initiate WebSocket connection ... /v1/database/<db>/subscribe?compression=Brotli: HTTP error: 401 Unauthorized。
  • 原因:SpacetimeDB SDK 订阅需要 Web API identity token;本地 .env.local 常把 GENARRATIVE_SPACETIME_TOKEN 留空,只靠 CLI 登录态 publish 成功并不能让 api-server 的 WebSocket subscribe 获得权限。
  • 处理:scripts/dev.mjs 在 SpacetimeDB 就绪后优先读取 <spacetimeDataDir>/dev-api-identities/<serverSha256>.json;缺失或不可用时才调用 /v1/identity 创建专用 Web API identity token,并以普通 0600 文件持久化。token 只注入 api-server,不写 .env.local、不传 Web / Vite、也不进日志。若仍报 401,先确认是否使用项目脚本启动、记录文件是否因 server 或权限不匹配被重建,以及 GENARRATIVE_SPACETIME_SERVER_URL / 数据库名是否指向本次启动的实例。
  • 验证:npm run test -- scripts/dev.test.ts;重新运行 npm run dev 后 api-server 启动日志不再出现上述 subscribe 401,/healthz 返回 200。
  • 关联:scripts/dev.mjs、scripts/dev.test.ts、docs/【开发运维】本地开发验证与生产运维-2026-05-15.md。

带 BOM 的 spacetime.local.json 会被忽略

  • 症状与机制:本地接口报 No such procedure 或订阅报 no such table,而预期数据库已发布时,核对实际 server/database。spacetime.local.json 带 UTF-8 BOM 会被 scripts/dev.mjs 忽略,启动可能仍指向另一份库。
  • 处理:确认 JSON 无 BOM,再按 .app/dev-stack.json 的实际地址查目标 schema;用显式 --database / --spacetime-port 重启 API,重放原失败调用。不要仅凭 /healthz 成功断定模块一致。
  • 关联:scripts/dev.mjs、docs/【开发运维】本地开发验证与生产运维-2026-05-15.md。

Windows junction 工作区的 Vitest 定向测试须从真实路径运行

  • 现象与原因:Vite / Vitest 将入口 realpath 到真实 worktree;从 junction 或映射路径传入相对测试参数时,入口和 resolved id 可能跨盘符不一致,误报文件不存在。
  • 处理:用 Get-Item <worktree> | Format-List Target 确认真实路径,切到该目录重跑同一定向测试;不要把测试收集或加载失败误判成组件、路由断言失败。
  • 验证:从真实路径执行后应正常收集并运行目标测试。

创作入口突然消失先查前后端是否串到不同 worktree

  • 现象:http://127.0.0.1:3000/ 可访问,但创作 Tab 里新增玩法入口消失;例如 puzzle-clear 已在代码默认种子中存在,浏览器仍看不到“拼消消”。
  • 原因:Vite 可能来自当前 worktree,但代理目标的 api-server 仍是另一个 worktree 的旧进程,或者 api-server 连到旧 SpacetimeDB 模块;此时 /api/creation-entry/config 会返回旧入口配置。
  • 处理:先用 Get-NetTCPConnection -State Listen -LocalPort 3000,8083,3103 结合 Get-CimInstance Win32_Process 确认端口进程路径;停止串线的旧 api-server,再用当前 worktree 的 npm run dev:spacetime -- --spacetime-port <port> --database <database> 和 npm run dev:api-server -- --api-port <port> --spacetime-port <port> --database <database> 拉起同一套服务。
  • 验证:GET /api/creation-entry/config 应包含目标入口,且监听端口的命令行都指向同一个 worktree;浏览器创作 Tab 对应分类应显示入口卡。
  • 关联:scripts/dev.mjs、.codex/skills/genarrative-dev-stack-port-routing/SKILL.md、docs/【玩法创作】平台入口与玩法链路-2026-05-15.md。

Windows junction 工作区下 dev.mjs 直接执行入口要用 realpath 判断

  • 现象:在 Windows junction 或映射 worktree 中运行 npm run dev:web,进程可能秒退且端口不监听;从真实 worktree 路径启动正常。
  • 原因:scripts/dev.mjs 的入口判断只比对 process.argv[1] 和 import.meta.url 的字面路径;junction 路径和 realpath 路径不一致时会误判成“不是直接执行”,于是主流程根本不进入。
  • 处理:入口判断改成基于 realpathSync(...) 的 isDirectModuleExecution(...),让 junction 路径和真实 worktree 路径指向同一个模块;同时补回归测试覆盖该场景。
  • 验证:npm run test -- scripts/dev.test.ts scripts/dev-stack-port-utils.test.ts 通过后,npm run dev:web -- --web-port 3000 --api-port 8083 --no-interactive 应能稳定把 0.0.0.0:3000 监听起来。
  • 关联:scripts/dev.mjs、scripts/dev.test.ts。

含中文 image2 live 验证不要用 PowerShell 管道喂 Node 源码

  • 现象:本地用 @'...'@ | node - 跑 VectorEngine / gpt-image-2 live 验证时,request.json 里的中文 prompt 可能全部变成 ????,生成图会变成完全不相关的 UI、建筑海报或其它随机内容,容易误判为模型不服从提示词。
  • 原因:Windows PowerShell 管道到 Node stdin 时可能按本机非 UTF-8 编码传输脚本文本,JS 源码里的中文字符串在进入 Node 前已经损坏;Rust 后端真实请求不会走这条编码路径。
  • 处理:含中文提示词的 live 验证优先写成 UTF-8 .mjs 文件再执行,或使用能确认 UTF-8 的运行入口;执行后先检查本次 request.json 是否保留真实中文,再判断生图质量。不要基于 ???? prompt 生成的图片调整项目提示词。
  • 验证:生成前后检查 request.json,其中 prompt 字段应显示中文而不是问号;同一提示词在 UTF-8 文件脚本下应能得到符合主题的图。
  • 关联:.codex/skills/gpt-image-2-apimart/SKILL.md、server-rs/crates/api-server/src/jump_hop.rs。

Tauri devUrl 不会自动跟随 dev:web 端口漂移

  • 现象:运行 npm run desktop-shell:dev 时终端显示主站 Vite 实际启动在 10000+ 端口,但 Tauri 窗口仍加载 http://127.0.0.1:3000/,桌面壳表现为白屏、连接失败或加载到旧页面。
  • 原因:Linux dev 端口段只把 CLI --web-port 视为显式端口;桌面壳 package script 里的 WEB_PORT=3000 会被端口段映射覆盖。Tauri devUrl 是静态配置,不会读取 scripts/dev.mjs 最终解析出的漂移端口。
  • 处理:桌面壳 beforeDevCommand 必须使用 npm --prefix ../.. run dev:web -- --web-port 3000 --strict-web-port,让 Vite 实际监听端口和 Tauri devUrl 一致,并在 3000 被占用时直接失败。若 3000 被占用,先释放占用进程再启动桌面壳,不要依赖 Vite 漂移。
  • 验证:npm run test -- scripts/dev.test.ts -t "Linux 桌面壳显式指定 web-port"、npm run desktop-shell:typecheck、实际启动时终端应显示 [dev] web: http://127.0.0.1:3000。
  • 关联:apps/desktop-shell/src-tauri/tauri.conf.json、apps/desktop-shell/scripts/check-config.mjs、scripts/dev.mjs。

Tauri 手动创建主窗口时 devUrl 不会自动套到 index.html

  • 现象:npm run desktop-shell:dev 启动后窗口地址显示 http://tauri.localhost/index.html 或 tauri://localhost/index.html,即使 Vite 已经在 http://127.0.0.1:3000/ 正常监听。
  • 原因:桌面壳为了注册导航、下载、生命周期和托盘行为,把 Tauri 配置里的主窗口设为 create=false,再在 Rust app.rs 中用 WebviewWindowBuilder::from_config(...) 手动创建窗口。此时如果只读取 app.windows[].url = index.html 并补 HostBridge query,手动窗口会沿 release 入口走打包资源协议;Tauri CLI 的 build.devUrl 不会自动替换这份手动克隆后的窗口 URL。
  • 处理:app.rs 在 dev build 下必须先把主窗口 URL 替换为 config.build.dev_url,再调用 desktop_window_config_with_runtime_platform(...) 补写宿主上下文;shell/navigation.rs 也必须允许 dev build 下的 http://127.0.0.1:3000 留在 WebView 内,不要把自己的 Vite 首页当外链交给系统浏览器。release build 保持 index.html 打包入口。
  • 验证:cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml desktop_main_window_config_uses_dev_url_in_dev_builds desktop_webview_navigation_stays_on_packaged_or_same_origin_pages,实际启动时窗口应加载 http://127.0.0.1:3000/... 而不是 tauri.localhost/index.html。
  • 关联:apps/desktop-shell/src-tauri/src/app.rs、apps/desktop-shell/src-tauri/src/shell/url.rs、apps/desktop-shell/src-tauri/src/shell/navigation.rs、apps/desktop-shell/src-tauri/tauri.conf.json。

沙箱内验收 fixture 不能依赖 Runtime 控制面或宿主 namespace 身份

  • 现象:V1.10 process-session 真实验收在 V1.11 后报“缺少精确回显”,但确定性 PTY 和 sandbox 测试均通过;旧 fixture 在 readiness 前写 .agent,还启动 loopback server 并把进程内 PID / 端口交给宿主检查。
  • 原因:V1.11 正确地用 0000 空 mount 隐藏 .agent,并隔离 pid / network namespace。沙箱内 PID、loopback 端口和 Runtime 控制目录不再是宿主可观察事实;fixture 在输出 readiness 前即可能失败,统一的 interaction-evidence 错误又掩盖了真实阶段。
  • 处理:交互 fixture 只使用 PTY stdin/stdout/signal,不写 .agent、不监听 TCP、不持久化 PID/端口。唯一启动由 process record、start action/fingerprint、start audit 和唯一 readiness marker共同证明;Runner 强杀后的清理由 owner boot、reconciliation record 和宿主 /proc/*/cwd 项目进程归零证明。
  • 验证:独立真实 Node smoke 必须完成 readiness、challenge 单行原样输入、精确 echo、SIGTERM stopped,并确认项目未创建 .agent;E2E 分别报告 readiness / stdin hash / echo / stopped 缺失,严格检查 readiness poll -> stdin -> echo poll -> terminate -> terminal poll。Provider 在零工具计划阶段的 502/TLS 只记外部失败,不得归因到 fixture 或 Runtime。
  • 关联:apps/ai-game-creator-shell/scripts/process-session-real-e2e-fixture.mjs、agent-runtime-real-e2e.mjs、apps/ai-game-creator-shell/tests/processSessionRealE2eFixture.test.ts、docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md。

loopback port 0 也会被临时端口池耗尽阻断

  • 现象:旧 Runner 已停止、endpoint 连接拒绝,但新 Runner 在 TcpListener::bind(127.0.0.1:0) 直接返回 Address already in use,所有 Agent 写命令随后报“Runner 在就绪前退出”;同一主机上的本地预览和 Node HTTP 测试夹具也会以相同方式失败。
  • 原因:port 0 仍需要内核从 ip_local_port_range 分配监听端口;本机 api-server 与 SpacetimeDB 的约 2.8 万双向连接占满 32768-60999 后,即使目标端口不是旧 endpoint 端口,自动分配也会失败。只看 ss -ltn 会漏掉占用本地端口的 established client socket。
  • 处理:先保留 port 0 正常路径;Linux 只在 AddrInUse 后懒读取 ip_local_port_range / ip_unprivileged_port_start / ip_local_reserved_ports,把候选限制在 61000-65535 高位段并排除临时范围、实际特权范围和 reserved ranges,再按随机起点尝试。Runner 与本地预览复用同一 loopback binder;必须交给外部测试进程监听时,先用有同等回退能力的测试 listener 预留端口并有限重试交接。不要停止用户 dev 栈,不要扫描常见服务低端口,不要使用非 loopback fallback,也不要用固定公开端口或无 token 协议绕过。
  • 验证:除纯 bind 回退、懒加载、非默认特权起点、reserved ranges、候选耗尽和范围解析单测外,还要在端口池真实耗尽的主机上启动 Runner 和本地预览,确认监听端口位于临时范围外、heartbeat 与预览内容可读,并完成真实 Provider 任务及 MCP HTTP fixture 全量回归。
  • 关联:apps/ai-game-creator-shell/src-tauri/src/runner.rs、preview.rs、tests.rs、agent-runtime-real-e2e.mjs、docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md。

隔离 AppData 的长 TMPDIR 会让 Chrome SingletonSocket 超限

  • 现象:静态检查已通过,但 preview.validate 在 Chrome 启动阶段以 status 134 失败;minidump 中是 process_singleton_posix.cc 的 Socket path too long,页面逻辑和截图探针均未开始。
  • 原因:真实 E2E 为隔离 Runner 创建的 AppData 路径较长,Chrome 又在 TMPDIR 下拼接 SingletonSocket 名称,最终超过 Unix domain socket 路径上限。
  • 处理:浏览器进程使用 /tmp/ga-browser-* 短临时根,同时把 Profile 放在该根目录并显式传入子进程 TMPDIR;项目证据目录不变。用真实长 TMPDIR 环境运行 Chrome smoke,不能只测临时目录字符串长度。
  • 关联:apps/ai-game-creator-shell/src-tauri/src/browser.rs。

AI 游戏创作壳不能用全局或匿名身份发布本地模块

  • 现象:npm run agc 在发布模块时先访问 auth.spacetimedb.com 并以 401 失败;改成 --anonymous 后首次可能成功,但再次启动会因匿名 identity 变化而 403。若把 403 当成可忽略警告继续启动,api-server 会连接旧 schema,随后持续输出 external_generation_job、profile_recharge_order_expiration_timer 等缺表订阅失败,Tauri 也可能在后端就绪前退出或迟迟不弹窗。
  • 原因:本地 publish 默认继承开发者全局 SpacetimeDB 云端登录,离线时 standalone 无法校验 issuer;--anonymous 不是可跨进程持久复用的 owner identity;AI 游戏创作壳若再复用主站历史数据目录,还会继承旧数据库归属和旧 schema。
  • 处理:AI 游戏创作壳固定使用 gitignored 的独立数据目录;standalone 就绪后先从 /v1/identity 获取并按 data dir 而非监听端口持久化同一 API identity,再用数据目录内权限为 0600 的独立 cli.toml 执行 spacetime login --token 和 publish。旧端口作用域记录在同一 data dir 下身份唯一时迁移,存在多个不同身份时失败关闭,不能猜 owner。远程 server 继续使用正常登录配置;本地 publish 403 必须阻断 API/Vite,不得带旧 schema 降级启动。.app/dev-stack.json 记录规范化 data dir,独立壳复用后端时必须同时匹配数据库名、专用目录和健康状态;缺少目录字段的旧状态不得复用。POSIX 启动器在 spawn 后立即监听 error / exit、保存 detached leader 的 PGID、向外层登记句柄并用独立进程组收束 npm、Node、Cargo 和子进程;direct leader 先退出后仍向负 PGID 发信号清理后代,ready 前中断、超时或 ENOENT 也走统一清理,退出后确认 3080、8082、3101 均释放。
  • macOS 日志:api-server 进程指标当前只实现 Windows API 和 Linux /proc,macOS 必须跳过 observable callback 注册;不能每轮采集为每个指标重复打印“不支持平台”。Rust/Tauri 既有 dead_code warning 与一次性配置缺失提示不属于长驻重试日志。非 Linux project.verify 校验 npm run 参数时必须越过 --silent、--ignore-scripts 等前置选项定位真实脚本名,不能固定读取 run 后第一个参数,否则会在 macOS 将合法验证误报为“缺少脚本名”并引发 Runtime 测试级联失败。
  • 验证:定向测试覆盖同一 data dir 跨端口复用 identity、不同 data dir 隔离、旧 state/data dir 不匹配拒绝复用、spawn ENOENT 受控失败、direct leader 以 42 退出后同组 descendant 仍收到 TERM,以及后端 ready 前句柄已登记且超时清理。连续运行两次 npm run agc,两次都必须真实完成 module publish、/v1/ping、/healthz、Vite 3080 和 Tauri Running;稳定观察期间不得出现缺表订阅失败或进程指标平台告警,Ctrl-C 后三个端口和主 Tauri 进程均应释放。
  • 关联:scripts/dev.mjs、apps/ai-game-creator-shell/scripts/start-dev-stack.mjs、server-rs/crates/api-server/src/process_metrics.rs。

Swarm 人工测试不能复用正式客户端 Runner AppData

  • 现象:npm run agc:test:chat 在进入聊天前报“Agent Runner 版本与当前客户端不一致,但旧 Runner 仍有任务,暂不能重启”;正式客户端仍能看到自己的待确认或委派任务,重复执行测试也持续失败。
  • 原因:Runner 复用身份同时绑定协议版本和当前可执行文件 SHA-256。cargo run 重新编译后的 debug 二进制与正在运行的 release Runner 指纹不同,而旧入口只隔离测试项目、仍把正式 AppData 直接传给 CLI,于是测试会向正式 endpoint 发升级探测。正式 Runner 有 pending action、Provider sidecar、进程会话或非终态队列时拒绝退出是正确的安全门禁,不能通过强退或放宽 idle 判定让测试通过。
  • 处理:正式 AppData 只作只读配置来源。每次人工测试在系统临时根创建 0700 sentinel 隔离目录,只把主配置和可选 local overlay 私有复制为 0600 普通文件;不得复制 endpoint、lock、.previous 或其它状态。LLM 检查与端到端测试入口全部使用隔离目录。退出时通过内部 CLI 请求 runner.shutdown_if_idle,确认隔离 endpoint 消失后才删除配置;仍有任务或无法确认退出时同时保留测试项目和隔离配置并报告路径。正式 Runner 的 PID、bootId、端口和 executable fingerprint 必须保持不变。
  • 验证:单元测试覆盖私有 inode、权限、local overlay、禁止复制 endpoint/lock/备份、符号链接拒绝、sentinel 清理和 endpoint 存在时拒绝删除;真实 smoke 使用隔离 AppData 启动并收束空闲 Runner,前后比较正式 endpoint 身份且确认正式 PID 存活,再检查本轮 /tmp 项目和隔离配置均已清理。
  • 关联:apps/ai-game-creator-shell/src-tauri/src/runner/client.rs、apps/ai-game-creator-shell/src-tauri/src/cli.rs。

AGC 配置向导必须保护密钥、私有目录和 stdin 状态

  • 根因:命令行明文 API Key 会进入历史和进程列表;POSIX mode 在 Windows 上不能代替 DACL,隐藏输入未恢复 stdin 状态也会让取消或失败后进程挂住。
  • 处理:GUI 与 npm run agc:config 共用 AppData game-creator.config.json,隐藏输入密钥并拒绝 --api-key。原子更新保留其它配置节点;POSIX 目录/文件权限为 0700 / 0600。显式 --config-dir 必须是 world.genarrative.ai-game-creator 独立叶目录,不得加固 AppData 根、临时根或共享目录。
  • Windows 边界:先为私有目录设置仅当前用户、禁止继承的 DACL;目标配置先创建空文件并收紧 DACL,再写密钥。PowerShell 加固参数通过子进程私有环境传入,避免 -Command 后的带空格路径被拼成命令。
  • 输入与测试:成功、取消、异常和信号路径恢复 raw mode 与原 pause 状态,信号恢复后重发。只测配置优先级的用例对读写两路使用同一 ACL 桩;真实 Windows DACL 仍需真机验证。输出只报告密钥是否配置,不读取或打印密钥本体。
  • 关联:apps/ai-game-creator-shell/scripts/game-creator-config-wizard.mjs、apps/ai-game-creator-shell/scripts/check-config.mjs。安全路径 fixture 按平台原生 realpath 构造期望值,避免 macOS /var 与 /private/var、Windows junction 别名导致假失败。

同一 Rust 二进制的本地双进程不能各自并发 watch 重启(2026-07-21)

  • 现象:本地把 api-server 与独立 bgfilter-worker 都用 cargo run -p api-server 启动后,一次 Rust 源码变更触发两套 watcher 并发停止、编译和链接;Windows 常因另一个实例仍占用 api-server.exe 而链接失败,或出现 API 已恢复但内部 worker 尚未 ready 的半更新状态。
  • 原因:两个进程角色共享同一 crate、target 和可执行文件,却被错误地当成两个互不相关的 dev service。更危险的是先启动 GENARRATIVE_PROCESS_ROLE=all 的 API:它会立即消费外部生成队列,可能在内部 BgFilter worker 尚未 ready 时领取任务。
  • 处理:npm run dev 与 npm run dev:api-server 只创建一套 Rust watcher,并把两个进程作为组合重启单元:先停止 API 与 BgFilter worker,再只让 worker 的 cargo run 完成必要构建,等待 worker /readyz,最后启动并验活 API。交互 rs api-server、rs bgfilter-worker 在完整栈内也必须走同一组合重启。ProcessRole::All 永远不内嵌 BgFilter listener;父子进程共享解析后的内部 base URL / Token,Linux 第五端口固定为端口段 start + 4,Windows 把第五端口纳入统一探测和漂移。
  • 验证:定向测试断言组合重启顺序为“stop API → stop worker → start/ready worker → start/ready API”,dev:api-server 自动带起同 runner worker,端口解析得到五个互不冲突的端口;再运行 node --check scripts/dev.mjs、dev-stack 定向测试和编码检查。
  • 关联:scripts/dev.mjs、scripts/dev-stack-port-utils.mjs、.codex/skills/genarrative-dev-stack-port-routing/SKILL.md、docs/【开发运维】本地开发验证与生产运维-2026-05-15.md。

Tauri beforeDevCommand 失败不等于已启动客户端会自动退出(2026-08-03)

  • 原因:Tauri 的字符串 beforeDevCommand 默认 wait=false。只要固定 devUrl 上已有可访问页面,Tauri CLI 可以在配套启动脚本完成前创建原生窗口;旧实现又直接从 npm 启动 Tauri CLI,没有在 CLI leader 退出后继续持有其 PGID / Windows 进程树。start-dev-stack.mjs 虽会在后端 ready 后识别 marker/API 错配,但检查时机已经晚于窗口创建,且只清理自己登记的后端和 Vite。
  • 2026-08-08 后续统一:上述 3080 是事故发生时的历史实现,不再是当前 Linux 启动口径。AGC Vite 已纳入系统级用户端口段,首选 start + 5,占用时只在本用户段内漂移;外层启动器把最终端口写入 Tauri CLI 动态 build.devUrl 和子进程 GENARRATIVE_AGC_VITE_PORT,并用 Vite CLI --port 启动严格监听。beforeDevCommand、配套后端预留、WebView 与 Vite strictPort 必须使用同一值。Windows / macOS 仅把 3080 保留为兼容首选并允许统一漂移。未知归属监听器仍不得复用或主动终止,但其它用户固定 3080 不再阻塞 Linux 当前用户启动。
  • Linux 容器边界:最小化 CI 容器的 PID 1 可能不回收孤儿后代,进程组在所有可执行成员退出后仍只剩 Z 僵尸;此时 kill(-pgid, 0) 仍成功,不能据此把已经完成的收束误报为失败。Linux 等待逻辑在 signal 探活后必须核对 /proc/<pid>/stat,只把同 PGID 的非 Z / X 成员视为存活;/proc 不可读时继续使用原保守判断,macOS 等其它 POSIX 平台仍只走 signal 探活。
  • 验证:定向测试必须覆盖用户段 start + 5 映射、同段占用漂移、父子启动器严格复用最终端口、动态 Tauri --config、marker 与预检地址一致、未知归属监听器拒绝复用、CLI leader 先退出后同 PGID 客户端仍收到 TERM、忽略 TERM 时升级 KILL,以及 Windows taskkill 的 /PID /T /F 参数。正常启动后退出,确认 Tauri 客户端、Runner 和本轮自有后端 / Vite 均按生命周期收束。
  • 关联:apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs、apps/ai-game-creator-shell/scripts/start-dev-stack.mjs、apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts、apps/ai-game-creator-shell/tests/start-dev-stack.test.ts。

macOS 安全路径测试必须使用规范化临时目录(2026-08-05)

  • 现象:调用仓库上下文、Runtime context bundle 或 pending recovery 的 Rust 测试在 macOS 报“Repository root and its ancestors must not be symbolic links”,Linux CI 却可能通过;本地 HTTP 恢复夹具在完整串行测试中还可能偶发 WouldBlock。
  • 原因:tempfile::tempdir() 默认返回 /var/folders/...,而 macOS 的 /var 是指向 /private/var 的符号链接,生产安全校验会按设计拒绝该祖先;恢复测试的服务端读超时若仅为 2 秒,也会与完整测试负载下约 2 秒的首次请求形成窄竞态。
  • 处理:凡测试会进入仓库可信路径校验,统一使用 crate::tests::canonical_test_tempdir(...),不得削弱生产符号链接拒绝规则;loopback 夹具保留有界超时,但为完整 CI 负载留足稳定裕量。
  • 验证:在 macOS 上定向运行 provider request、pending recovery、autonomous continuation 与 generation recovery 用例,再运行完整 npm run check:native-shells。

manifest relay 测试不能并行覆盖同一个全局 sink(2026-08-05)

  • 现象:crate 根 relay 测试在配置全局 sink 后阻塞等待 TcpListener::accept(),同时 Runner GUI owner attach 测试通过另一条路径覆盖并清空 sink;事件可能被发往另一端口,原 listener 随后永久等待。断言或 expect 提前失败时,成功路径末尾的手动 clear 也不会执行。
  • 原因:两个跨模块测试读写同一进程全局状态,却没有共用隔离边界;只给 accept 后取得的 stream 设置 read timeout 无法约束 accept 本身,payload 读取也缺少总 deadline。
  • 处理:全部全局 sink 测试共用一把 test-only 串行锁,并由 RAII guard 在 Drop 中无条件清空;测试统一使用 manifest_invalidation_sink_isolation_ 前缀。relay fixture 对 accept 和 payload 分别使用非阻塞轮询与总 deadline,不使用固定 sleep;生产 loopback、token、连接 / 写入超时和 payload 大小校验保持不变。
  • 验证:用 --test-threads=2 重复运行统一 filter,覆盖正常 relay、无事件 accept 超时、不完整 payload 超时、panic 展开清理,以及 GUI owner attach 配置与 guard 清理。

模拟 Provider 测试必须显式固定 Agent 执行模式

  • 现象与原因:只配置 mock baseUrl / apiKey / model 或 agentLlm,不代表选择 HTTP Provider。新安装或无迁移上下文的隔离 AppData 默认使用 codex_app_server,测试会启动本机 CLI、依赖认证环境,mock server 收不到请求并超时;本机安装 Codex 还会掩盖 CI 失败。
  • 处理:断言 HTTP Provider 请求的测试必须显式写入 agentMode=provider。测试 helper 可统一补齐,但直接写隔离 AppData 或切换 runtime config dir 的 fixture 仍须自行声明,不能依赖用户配置、仓库环境或历史迁移。只测 retry/handoff identity 的纯单测应调用显式接收模式的 helper。不得为单测更改生产默认模式或向通用 CI 安装 Codex CLI。
  • 验证:在 PATH 不含 Codex CLI 时运行 response-stream identity、MCP Runtime 和平台素材 mock 回归,确认请求数量、顺序命中 mock server,且该用例没有启动 app-server;保留独立的 Codex 可用性与协议测试,Provider 测试不能替代正式模式覆盖。

Windows MSVC 测试不要依赖系统 OpenSSL(2026-08-17)

  • 现象:Windows 上执行 Rust 测试时,openssl-sys 构建脚本因找不到 OpenSSL 开发目录或 vcpkg 而失败;机器即使带有 Strawberry Perl 的 openssl.exe 和 libssl.a,也不能直接供 x86_64-pc-windows-msvc 链接。
  • 原因:测试代码仅为动态生成 RSA fixture 引入 openssl dev-dependency,从而把原本使用纯 Rust 加密实现的 crate 额外绑定到本机原生 OpenSSL 工具链;Strawberry 附带的库面向 MinGW,不等于可用的 MSVC OpenSSL SDK。
  • 处理:测试优先使用明确标记、只供测试的固定 PEM fixture,并继续通过项目自身的密钥解析与签名验证路径覆盖真实行为;不要仅为生成 fixture 引入系统原生库,也不要把 MinGW OpenSSL 路径写入 OPENSSL_DIR 冒充 MSVC 依赖。
  • 验证:运行目标 crate 的 cargo tree -i openssl-sys --target all 确认依赖已退出,再执行包含测试目标的 cargo test --tests,不能只用不会编译 dev-dependency 的 cargo check --lib 代替。

独立 Cargo workspace 的测试增量缓存会吞噬数百 GiB(2026-08-22)

  • 原因:两个 Cargo workspace 拥有独立 target 和锁文件,AGC 又以 path dependency 复用若干 server-rs crate;更主要的是全量 / 分组 cargo test 继承增量编译,每组 crate / feature / profile hash 都可以留下新会话,Cargo 不会按仓库期望自动收缩这些历史目录。
  • 处理:保留两个 workspace 的产品 / 发布边界;两边 [profile.test] 关闭 incremental 并固定 debug=1,AGC dev profile 与 server-rs 对齐调试信息级别。日常用 npm run audit:rust-build-cache 只读核对;需要回收时先停止 Cargo / rustc,再显式运行 npm run clean:rust-incremental -- --apply,只删两个固定增量目录。
  • 验证:清理前后各跑一次只读审计并核对磁盘可用空间;分别运行 server-rs 与 AGC 定向 cargo test,确认 test profile 不再生成持久 debug/incremental 堆积。共享 CARGO_TARGET_DIR 必须另做并发启动基准,不得为节省磁盘直接改变生产产物路径。

Tauri 前端等待超时可能由配套 worker 先失败引起

  • 症状与机制:AGC Vite 等配套后端全部就绪才启动;worker 端口被旧进程占用时,只让 API 漂移仍会启动失败,最终却表现为 Tauri 前端等待超时与 code=143。
  • 处理:先核对 .app/dev-stack.json 的各服务状态和监听进程,API、worker 与 SpacetimeDB 使用同一组实际地址。外层启动器先准备服务再启动 Tauri,并收束自有服务树,不让冷编译/publish 挤占前端等待;自动发布保留数据库,不能靠清库绕过失败。
  • 关联:docs/【开发运维】本地开发验证与生产运维-2026-05-15.md 的本地启动段。

跨平台“死进程 PID” fixture 必须落在 Unix 有符号 32 位范围内(2026-09-09)

  • 现象:project_lock_recovery::project_write_lock_reclaims_dead_owner_pid 在 Windows 本地通过,在 Linux CI 报“死进程残留锁未被回收,实际错误:项目正在被其他写操作占用”。
  • 原因:fixture 用 0xFFFF_FFF0 当死 PID;Unix 的 pid_t 是有符号 32 位,i32::try_from 直接失败,存活判定返回 None(无法判定)而不是 Some(false),于是落回 600 秒保守分支,残留锁不再被回收。
  • 处理:实现层把“平台不可能分配出的进程号”(0 或超出平台 pid 宽度)判为持有者不存在并直接回收;fixture 改用 i32::MAX as u64 - 1,另加 u64::MAX 非法进程号用例。
  • 关联:apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs、apps/ai-game-creator-shell/src-tauri/src/tests/project_lock_recovery.rs。

同一工作树里残留 dev stack 会拖死 Rust 门禁(2026-09-10)

  • 现象:cargo test 长时间不返回,或报 failed to remove file ...genarrative-ai-game-creator-shell.exe / os error 5;增量编译退化成像全量重编。
  • 原因:同一工作树里还挂着 npm run agc(scripts/start-tauri-dev.mjs → tauri dev)等进程。它的文件 watcher 会被 Rust 源码改动反复触发全量重编译,与 cargo test 抢同一个 target 锁,同时占住产出的 .exe。
  • 处理:跑 Rust 门禁前先确认该工作树没有 start-tauri-dev / tauri dev / vite / api-server / spacetimedb 进程,必要时按进程树结束;多工作树并行时各工作树有独立 target,但不要在同一个工作树里重复起第二个 cargo check。
  • 关联:apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs、apps/ai-game-creator-shell/src-tauri/target。

2026-09-11 裸 rustfmt 会把「一个文件」放大成整个模块树,并行工作树里不要盲跑整仓 fmt

  • 现象:只想按 check:rustfmt 的报错格式化 1 个 Rust 文件,跑完 git diff --stat 却显示 src/agent/ 下 50 个文件被改动(cargo fmt --all 之后同样会扫到别人未提交的半成品)。
  • 原因:裸 rustfmt <file> 把传入文件当成模块根,会跟随该文件的 mod 声明递归格式化所有子模块;cargo fmt -- <文件...> 才只作用于列出的文件。两者作用域不同,--check 的报错范围也因此不同——只修 --check 报出来的那几行不够,要用同一作用域复核。
  • 处理:改格式前先 git diff --stat 确认范围并备份将被改动的文件;需要精确到文件时用 cargo fmt -p <pkg> -- <文件...>(只格式化列出的文件),或 rustfmt --config skip_children=true(注意 skip_children 下的折行口径可能仍与 cargo fmt 不一致,最终以 cargo fmt -p <pkg> -- --check <文件...> 为准)。多人并行的工作树里禁止盲跑 cargo fmt --all,它会格式化别人未提交的半成品。误伤后先备份、再用 git apply -R 反向补丁精确还原,不要 git checkout --。
  • 关联:rustfmt.toml、package.json 的 check:rustfmt(server-rs 与 AGC src-tauri 两条命令,CI 常在第一条就退出,第二条从未跑到)。

2026-09-11 worktree 里 MSYS bash 跑 git 脚本会失效,门禁要按同序逐条等价执行

  • 现象:在 .worktrees/ 工作树里执行 npm run check:repository-ci(内部是 bash 脚本),第一步就报 [repository-ci] 比较基线不可用: <sha>;直接用 bash 诊断则是 fatal: not a git repository: /mnt/c/.../<worktree>/C:/Users/.../.git/worktrees/<name>。
  • 原因:bash 走 MSYS 的 /usr/bin/git,而 worktree 的 .git 是文件、内容为 Windows 绝对路径 C:/Users/.../.git/worktrees/<name>;MSYS git 把它当相对路径拼到当前目录后,解析成 /mnt/c/.../C:/Users/...。同一个 ref 用 Windows git 验证 git cat-file -e <sha>^{commit} 返回 0,所以这不是基线选错,而是 worktree + MSYS 不兼容。
  • 处理:在这类 worktree 里跑 script 门禁时,按脚本同序、同命令用 Windows git / PowerShell 逐条等价执行(SPACETIME_SCHEMA_BASE_REF=<base> 之类环境变量照传),不要因为 bash 包装层失败就判定门禁本身红。
  • 关联:scripts/check-repository-ci.sh、scripts/run-bash-script.mjs。

2026-09-11 资源画本用例在 jsdom 下永远拿不到 fit key,"总览→栏目"往返会重新适配视口

  • 现象:删掉左侧栏目大纲导航后,切栏目只能走「资源总览」缩略卡片;把测试 helper 从"点导航"改成"先 收起资源 回总览、再点缩略卡片"后,preserves independent art viewports across sort and workbench mode switches 里"回到依赖模式应恢复该模式 viewport"的断言变红:期望 332,236.4,0.9,实际 360,256,1(第一次 fit 的值)。
  • 原因:栏目页的首屏适配有 fit key 门禁——openResourceBookChild 只在 resourceBookSceneSize.width/height > 0 时把 <projectPath>\n<projectId>\n<sortMode>\n<category> 写进 resourceCanvasFitKeysRef,分页画布 layout effect 也同样要求可测量尺寸。jsdom 没有布局,两者恒为 0,于是每次 openResourceBookChild 都走 fit 分支,已存的 viewport 被重拟合覆盖。真机上尺寸非零、首次进入即写 key,再进入走 resourceCanvasViewportTargetsRef[sortMode][category] 恢复分支,所以这不是产品回归,是 jsdom 产物。
  • 处理:旧断言里那些"点导航切到当前栏目"的调用本来就是提前返回的空操作(openResourceBookChild 在 view/category/phase 未变时直接 return),改 helper 后它们变成了真实往返。修法是保留全部断言、只去掉这些多余往返(排序模式切换本身就会切换 viewport),不要为了让用例变绿去改 index.tsx 的 fit 逻辑,也不要把往返路径当成产品缺陷去"修"。
  • 推广:任何"进入栏目后应恢复上次 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。

稳定版 AGC 复用开发栈必须核对 instance identity

  • 现象:端口和 /healthz 都正常,但 AGC 连接了另一个 worktree 的 API、SpacetimeDB 或旧 Vite,表现为登录、项目列表、Runner 状态与当前代码不一致。
  • 原因:健康检查只能证明“有服务响应”,不能证明服务属于当前工作树;旧 .app/dev-stack.json 可能没有当前 repoRoot、instanceId 和服务级 dataDir 身份。
  • 处理:先读取 .app/dev-stack.json,核对顶层 repoRoot + instanceId,再核对服务 repoRoot + instanceId + dataDir + pid + port;AGC Vite marker 还必须带 repoRoot + processId + port。任何字段缺失或不匹配都拒绝静默复用,改为启动当前工作树自己的服务或明确提示清理。
  • 验证:scripts/dev.test.ts、apps/ai-game-creator-shell/tests/start-dev-stack.test.ts 覆盖 snapshot identity 和旧状态拒绝复用;运行时记录实际端口、进程命令行和 dataDir,不要只记录 HTTP 200。

Git hook 测试必须清除继承的 Git 仓库环境

  • 在 hook 内运行临时仓库测试时,cwd 不会覆盖继承的 GIT_DIR、GIT_WORK_TREE 或 GIT_INDEX_FILE。未隔离的 Git/lint-staged 子进程可能向真实仓库提交 fixture,甚至把测试版 ESLint、Prettier 配置带入主分支。
  • fixture 子进程统一清除 GIT_* 环境,并用一次性外层 linked worktree 验证引用、索引、配置不变;原有工程检查规则保持完整,不能用逐项关闭规则修复 fixture 污染。
  • 2026-09-16 复核:从链接工作树 git push/git commit 时,Git 注入 GIT_DIR=<主仓库>/.git/worktrees/<name>、GIT_WORK_TREE、GIT_INDEX_FILE,husky → npm → check:repository-ci → 夹具测试整链条继承。夹具 git config user.name "Git Hooks Test" 会写进共享 .git/config(此后所有提交 author 变成 Git Hooks Test);夹具 git init 按是否带 GIT_WORK_TREE 分别写成 core.bare=true(fatal: this operation must be run in a work tree)或 core.worktree=<临时夹具目录>(git status 实际在操作临时目录)。
  • 处理:钩子与门禁入口先 unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_CONFIG_PARAMETERS GIT_CEILING_DIRECTORIES;夹具 Git 调用在命令前自检 rev-parse --show-toplevel 等于夹具目录,落到外部仓库立即失败;守卫用例的子进程必须真的继承 GIT_DIR,否则断言会空转。
  • 验证:git config --show-origin --get user.name 出现 file:.git/config Git Hooks Test、git rev-parse --show-toplevel 指向 %TEMP%\genarrative-pre-push-*\repo 都是被污染的确定性证据;被 core.worktree 劫持期间执行的 git pull 会把检出写进临时目录,真实工作树整体落后(本次 93 个文件),配置修好后用 git checkout HEAD -- . 回填。
  • Vitest 的 toHaveBeenCalledWith 匹配任意一次调用,失败输出会列出其它命令;应先定位相同命令的真实参数差异,不能由其它调用的序号推断时序故障。
  • 存在后台轮询的 IPC mock 不应要求目标命令占据全局最后一次调用。验证刷新时先记录调用边界,再筛选该边界之后的目标命令,严格核对其最后一次参数,避免后台查询影响断言,也避免旧调用掩盖刷新未执行。

Windows 本机跑 server-rs 全量测试时先区分环境失败

  • wallet_refund_outbox 用 fs::hard_link 在 %TEMP% 下原子发布;若 Windows 返回 PermissionDenied(OS error 5),先核对本机硬链接权限与 Linux CI 结果,不要为本机限制放宽正式发布语义。
  • server-manager-panel::fonts::tests::finds_existing_system_cjk_font 依赖 fontconfig 的 fc-match,Windows 本机没有该命令时不能用它判断字体逻辑是否回归。完整 workspace 测试以 Linux CI 为准;其它失败仍需单独排查。

Cargo 子串过滤仍可能运行嵌套的绑定导出测试

  • 触发与机制:AGC export_bindings 测试会重写 chat/generated、services/generated 等绑定;cargo test 的普通过滤参数按子串匹配,仅指定父模块仍可能命中嵌套导出用例。
  • 处理:不需要导出时显式加 --skip export_bindings;需要导出时保持 ts-rs 原始输出,不套旧 Prettier 归一化流程,再运行 npm run check:generated-bindings 核对实际漂移。绑定不一致时核对 Rust 导出源与消费方,不盲目恢复整个生成目录。
  • 关联:scripts/check-generated-bindings.mjs、.prettierignore、.gitattributes。

2026-09-20 复用注册端口段时可能连到别的 worktree 的 SpacetimeDB

  • 现象:在 /data/dsk/Genarrative 跑 npm run dev:api-server 后,日志显示端口段 10000-10099 (dsk)、spacetime http://127.0.0.1:10002,但 api-server 反复报 ws://127.0.0.1:10002/v1/database/xushi-p4wfr/subscribe 返回 HTTP error: 404 Not Found,且始终不响应 /healthz。
  • 原因:该端口段是按用户登记的,同一用户的其他 worktree 实例已占用 10002;启动器只探测到端口被占用就按"已复用"继续,api-server 于是连到了另一份 data-dir 的 standalone,那里没有当前 database,发布步骤也没有落到这个实例上。api-server 在启动恢复阶段会一直重试,accept 了连接但不返回任何响应,所以 curl 表现为超时而不是连接拒绝。
  • 处理(现行口径):核对 ss -ltnp | grep :10002 的进程与 --data-dir 是否属于当前仓库;不属于就换用空闲端口段(GENARRATIVE_DEV_PORT_RANGE / --port-range)或先停掉确认无用的实例,不要把 404 当作 schema 缺失去改代码。排查"健康检查通过但接口 404"时不要只跑 /healthz。
  • 关联:scripts/dev.mjs、scripts/dev-stack-port-utils.mjs、/var/tmp/genarrative-dev-port-ranges/registry.json、.codex/skills/genarrative-dev-stack-port-routing/SKILL.md。

2026-09-20 新增 API 命名空间在本地返回 404:Vite 代理是前缀白名单

  • 现象:api-server 上 GET /api/game-distribution/games 直连返回 200,但浏览器里 http://127.0.0.1:<web>/games 报 404,页面显示「读取游戏目录失败」。同一 URL 换成 curl 直连后端却正常。
  • 原因:vite.config.ts 的 server.proxy 是逐个前缀白名单(/api/auth、/api/profile、/api/runtime、/api/editor、/api/assets、/api/llm、/api/ws),没有兜底 /api/。未登记的新命名空间不会转发到 Rust 后端,而是回退到 SPA 静态资源,前端再按 JSON 解析就失败。生产 nginx 走的是通用 location ^~ /api/,所以症状只出现在本地 dev。
  • 处理(现行口径):新增任何 /api/<namespace> 时,同一次变更里补 vite.config.ts 代理项和 src/config/viteProxyConfig.test.ts 断言;src/config/** 已加入 vitest.config.ts 的 include,漏测会直接红。注意该测试文件里可能残留已退役前缀(例如已退役的 /api/creation-entry)的断言,退役命名空间按「四不写」直接删断言,不要为它补代理。
  • 关联:vite.config.ts、src/config/viteProxyConfig.test.ts、vitest.config.ts、server-rs/crates/api-server/src/app.rs、deploy/nginx/genarrative.conf。

2026-09-20 在 jsdom 里把 AGC 发布接到真实后端:Blob 没有 arrayBuffer,且跨 realm BodyInit 会被 undici 拒绝

  • 现象:给 AGC 的 publishLocalProjectGame 写“默认跳过”的真实后端集成测试时,创建游戏、创建版本都成功,只有上传 ZIP 报“无法连接登录服务”(networkError: true),服务端访问日志里也没有这次上传。
  • 原因:测试跑在 jsdom 环境,fetch 是 Node(undici),但请求体是 jsdom 的 Blob:① 该 jsdom 版本的 Blob 没有 arrayBuffer()(typeof blob.arrayBuffer === 'undefined'),直接调用会抛异常;② 即便拿到字节,jsdom realm 的 ArrayBuffer/Uint8Array 也不是 undici 认得的 BodyInit。
  • 处理(现行口径):桥接层用 FileReader.readAsArrayBuffer 读 jsdom Blob(有 arrayBuffer 时才走它),再用 Buffer.from(new Uint8Array(...)) 复制成 Node 侧 Buffer 交给 undici。参考 apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts。
  • 关联:apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts、apps/ai-game-creator-shell/src/services/clientHttp.ts。

2026-09-20 AGC 导出后自动弹发布面板:焦点陷阱会吞掉模态之外的点击,既有导出快捷操作用例变红

  • 现象:给 AGC 加「导出试玩包后自动打开发布到游戏广场面板」后,appSurface.test.ts 的「预览快捷操作:导出确认、取消与包列表」变红——期望点消息上的「显示目录」把 /open-project 填进输入框,实际输入框仍是空。把发布面板的自动打开去掉,或用例先关掉面板,就恢复绿色。
  • 原因:同目录的 ThemedModal 用 createPortal + focus-trap-react 渲染模态。焦点陷阱存在时,模态之外的 click 不会触达 React 的处理器(实测:临时把 FocusTrap 换成普通 div、其余不动,同一个被模态遮住的按钮点击立刻恢复生效),所以自动化里“点模态背后的按钮”不会报错,只是静默无效。
  • 处理(现行口径):① 产品行为保留“导出成功后自动打开面板”(一键发布入口),但受影响的用例必须先用 findByRole('dialog', { name: '发布到游戏广场' }) 断言面板出现、点「关闭发布面板」再继续后续会话操作;② 给这类“新增自动弹窗”改流程时,先跑一遍相关 appSurface 用例,避免只跑新增用例;③ 排查同类“点了没反应”时,先看当前是否有焦点陷阱模态打开,而不是先怀疑事件绑定或状态。
  • 关联:apps/ai-game-creator-shell/src/App.tsx(setPublishPanelOpen(true))、apps/ai-game-creator-shell/src/components/modal/ThemedModal.tsx、apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx、apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-tools-and-preview.ts。

2026-09-24 根 vitest 白名单漏掉现役模块测试:文档里的验收命令变成 "No test files found"

  • 现象:npm test -- src/services/sseStream.test.ts(SSE 传输层收口约定与静态预览 MVP 验收清单里都写着的验收命令)报 No test files found, exiting with code 1;同一时期 apps/ai-game-creator-shell/tests/resourceBatchTagsIntegration.test.tsx、resourceTagStatsRefresh.test.tsx 在 --root apps/ai-game-creator-shell 口径下整文件以 ReferenceError: describe is not defined 失败、0 个用例执行。
  • 原因:e9c3dc112 退役旧创作模板业务(2026-07-17)把根 vitest.config.ts 的 src/**/*.test.ts(x) 通配收成显式白名单,只列了「当时确认存活」的目录;src/services/sseStream.test.ts、src/services/frontendRuntimeConfigService.test.ts、src/persistence/**、src/routing/RouteImageReadyGate.test.ts、src/editor/shared/jsonClient.test.ts、src/components/platform-entry/PlatformProfilePrimitives.test.tsx、packages/shared/src/theme.test.ts、AGC 的 tf2css.test.tsx 这些现役模块的用例被一起漏掉(用临时配置收进来跑:261 个文件全绿)。同时 194 个 AGC 测试文件里有 2 个只依赖 describe/it 全局、没有显式导入,换执行口径就整文件不收集。
  • 处理:把上面这些现役用例补回 vitest.config.ts 的 include(不恢复 src/** 通配,避免把旧创作链路的退役测试一起收回来);那两个 AGC 文件按同目录套件惯例从 ./appSurface/harness 显式导入 describe / it;同步修订两份文档里指向已删除模块的验收命令。

2026-09-28 AGC 项目快照有单用户每小时 3000 次文件上传配额,别用超大项目跑真实链路冒烟

  • 现象:server-rs/crates/api-server/src/project_snapshots.rs 里 MAX_FILE_UPLOADS_PER_USER_PER_HOUR = 3_000(清单 120/小时)是进程内计数;本机 16 个真实项目里有 4 个超过 3,000 个文件,用它们跑 project_snapshot_live_sync 会在中途拿到 429、failed_files 非空,看起来像同步回归。
  • 判据/做法:真实链路冒烟挑 1,000~2,000 个文件量级、且带 .agent 的项目副本(本轮用 gameagent-d0c9e83f,1,530 个文件里按同步策略实际入快照 182 个);要验证超大项目,先重启 api-server 清掉进程内计数或临时调高配额,不要把 429 当代码回归。
  • 关联:scripts/check-agc-project-snapshot-admin-http.mjs(后台列表 + ZIP 下载核对)与 apps/ai-game-creator-shell/src-tauri/src/project_snapshot/tests.rs 的 ignored live smoke。

2026-09-28 两段式重启 E2E 的管理员账号由 api-server 启动环境决定,重启后换凭据会让管理员断言全红

  • 现象:check:game-distribution-validation-restart-e2e 的 prepare 段打印 管理员登录成功 :: status=200,杀掉 api-server 重启后,用同一组 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD 跑 resume 变成 status=401,连带 /admin/api/* 的三条断言(待审队列、后台游戏列表、失败版本可见)一起 FAIL,看起来像重启把后台状态弄丢了;同一轮里作者登录、upload-state、重新确认、发行包读取全都正常。
  • 根因:超管登录直接比对 state.admin_runtime() 中的进程启动配置,不走数据库用户表;重启时更换环境配置会导致同一组 E2E 凭据失效。
  • 做法:两段式 E2E 的两次启动必须使用同一组管理员凭据;遇到 401 先核对实际启动配置,再排查业务链路。
  • 附注:同一个 E2E_STATE_FILE 只能 resume 一次。resume 段会额外建一个坏包版本,第二次跑「后台只看到两个版本」这类断言会因为多出 upload_failed 版本而失败;需要重跑就重新执行 prepare。同理,prepare 依赖 game-distribution:publish 灰度对该作者开启,脚本自己会先 PUT 特征开关。

2026-09-28 check:production-health-patrol 在 Windows 本机必然 FAILED,别当成回归

  • 现象:本机跑 npm run check:production-health-patrol 输出 [check:production-health-patrol] FAILED,理由是「nginx gateway mode 巡检应成功。预期退出码 0,实际 2」,巡检 JSON 里 6 条 service:* 全是 服务状态异常: spawn systemctl ENOENT。同一轮的 api:/healthz、bgfilter:/readyz、spacetimedb:/v1/ping、public:/ 都是 200。
  • 根因:这个 harness 用桩 systemctl 驱动巡检脚本,Windows 上 spawn systemctl 直接 ENOENT,服务态检查不可能通过;它验证的是 Linux + systemd 的生产形态。
  • 做法:本机只跑 npm run check:production-health-patrol-env(本轮 OK)确认巡检变量口径;check:production-health-patrol 留给服务器/CI 复核,别据此判定巡检脚本本身坏了。

2026-09-29 Vite dev 冷启动会让 web E2E 的首个 goto 超时,别当成页面回归

  • 现象:npm run dev 冷启动后,发行 web E2E 的 API 断言通过,浏览器首次进入 /games、详情或游玩页却在导航或元素等待处超时。
  • 原因与处理:Vite 会按需编译浏览器实际加载的路由模块;单纯 fetch 路由 URL 只拿到 SPA HTML,不能完成预热。用一次性浏览器 context 实际走过相关路由,再创建断言用的 context;冷导航保留足够的超时。
  • 排查:若失败恰在该路由首次浏览器加载处,先核对冷编译耗时;不要把 API 已通过、浏览器首次超时直接判为页面逻辑回归。

AGC 测试必须进入类型门禁,并使用项目 Node 环境

  • 根因:Vitest 转译不做类型检查,未纳入 tsconfig 的 satisfies 与 mock 泛型只是空断言;桩与真实类型脱节也不会被运行时通过数发现。
  • 现行门禁:tsconfig.tests.json 全量覆盖 AGC src/、tests/ 与 Vite 配置,check:tests:types 已并入 app typecheck。新增测试自动纳入,修正真实契约,不引入类型基线或豁免;具体口径见 development-workflow.md。
  • Vitest 0.34 契约:vi.fn<TArgs, R> 的 TArgs 是参数元组,使用 vi.fn<[X], R>() 或 vi.fn(impl),不能套用新版本的函数类型泛型。noUncheckedIndexedAccess 下 mock calls 要明确存在性;.mjs 推导过窄时按运行时真实契约声明局部签名。
  • 环境边界:本仓 CI 固定 Node 22。较新 Node 的全局 Web Storage 会压过 Vitest 0.34 jsdom 的 Storage,出现 window.localStorage 为 undefined;按项目 Node 22 环境复验,不用 --localstorage-file 绕过,否则会把 Node 文件型 Storage 引入多个用例的共享状态。
  • 关联:apps/ai-game-creator-shell/tsconfig.tests.json、该 app package.json、deploy/container/gitea-ci-job.Dockerfile、docs/project-memory/shared-memory/development-workflow.md。

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。

AGC Rust 测试需隔离后台通知,并保证 terminate 时目标仍运行

  • 通知根因:--test-threads=1 只串行测试线程,前一用例触发的 tauri::async_runtime 后台回合仍能跨用例广播。进程级 Atomic 计数会把其它回合通知算入当前断言。
  • 通知处理:同步通知断言的测试计数保留 thread_local! Cell,只统计本测试线程发出的通知,不能因搬迁降级为进程全局量。
  • terminate 根因与处理:leader 打印 READY 后立刻退出会提前开启后代清理宽限;CI 调度稍慢,terminate 发出时会话已经按 exited 收口。测试 leader 应用 wait 等后台子进程,确保 terminate 落在仍 running 的会话,保留原清理 marker 与延迟断言。
  • 验证边界:terminate 用例只在 Linux 内核上验证;Windows 上未运行不能算通过。分片失败复核须按本片实际名单匹配完整用例名,且保留首次失败现场。
  • 关联:apps/ai-game-creator-shell/src-tauri/src/agent/direct_events.rs、src-tauri/src/process_session/tests.rs、command_sandbox_trampoline.rs、scripts/run-rust-shell-test-shards.mjs。

2026-10-06 合并 master 后 schema guard 的基线盲区:本地全绿、CI 红(表字段相对顺序)

  • 现象:合并 master(merge 提交 eeb101458,PR base 9f4c7d76)后本地整套门禁全绿,CI 的 Backend tests 与 Repository checks 却都在 check:spacetime-schema 红:表 game_distribution_game 的第 26 个字段从 price_mud_points 变为 fork_authorization,疑似字段顺序被调整。
  • 原因:schema guard 对表的字段相对顺序是按 index 逐位比对的(baseTable.fields[index] vs currentTable.fields[index]);而不显式给基线时它取 git merge-base HEAD origin/master——merge 提交还没建时那还是旧 merge-base,于是「两侧各自在表尾追加」被并成「我们的列插到了 master 的列前面」这件事本地完全看不见;merge 提交一建,基线变成 PR base,CI 立刻红。
  • 处理(现行口径):合并 master 后先建 merge 提交,再用 PR base 复跑 SPACETIME_SCHEMA_BASE_REF=<PR base> npm run check:spacetime-schema。列顺序规则:master 先追加的列保持原 index,我们的追加排它之后(price_mud_points 第 26 位、fork_authorization 表尾);对应的 SpacetimeType 快照(GameDistributionGameSnapshot)按同一条纪律排,避免只改表不改快照。
  • 别踩:migration.rs 白名单、DTO parity 成员、nginx SPA 路由、Pingora 路由清单都是集合/无序判定(各脚本内部 Set/difference),只有 SpacetimeDB 表字段顺序(以及同步生成的 module_bindings/* 里的 wire 顺序)对相对顺序敏感;改列序后必须重跑 spacetime generate 并只回写受影响文件。
  • 判据/取证:SPACETIME_SCHEMA_BASE_REF=9f4c7d76 npm run check:spacetime-schema(exit 0)、cargo check --all-targets 0、cargo test -p api-server game_distribution(101 passed)、cargo test -p spacetime-module(294 passed)。
  • 关联:server-rs/crates/spacetime-module/src/game_distribution.rs(game 表与 game 快照的列序注释)、server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_{type,snapshot_type}.rs、scripts/check-spacetime-schema-guard.mjs、scripts/check-repository-ci.sh(CI 侧基线传法)。

构建、打包与发布

2026-10-07 cargo 目标目录里被"刷新 mtime"的陈旧 shared-contracts 会让编译报源文件里明明存在的字段缺失

  • 现象:cargo check 在 AGC shell 上报 unresolved import shared_contracts::runtime::ProfileMembershipUpgradeQuoteResponse、no field project_version / publication、LlmModelsResponse: Deserialize 等一整组「契约落后」错误;但 server-rs/crates/shared-contracts/src 里这些符号确实存在,git status 干净,刚重建的 rlib 也含符号。
  • 根因:target/debug/deps 里留着早先构建的 libshared_contracts-<hash>.rmeta,其 .d 依赖文件停留在旧时间戳,而 .rmeta 的 mtime 在快照 / 拷贝过程中被刷新成新时间;cargo 按 mtime 判定该 crate 仍然新鲜,于是把 --extern shared_contracts= 指到旧 rmeta,下游就看到旧 API。多份不同 feature 组合的 shared-contracts-<hash> 并存时更容易踩中。
  • 处理:删除该 crate 的全部指纹与产物后重编即可,不必清整棵 target:rm -rf target/debug/.fingerprint/shared-contracts-* target/debug/deps/*shared_contracts*,再跑 cargo check。判断依据是错误集中在某个 shared-contracts API,而源文件与 git status 都正常;先用 cargo check -v 2>&1 | grep -m1 -- '--extern shared_contracts=' 找到实际使用的 rmeta,再核对它同名 .d 里的源文件路径与时间戳。
  • 边界:这是构建缓存 / 快照产物问题,不是契约真源问题;不要因为这类报错去改 shared-contracts 或回退下游代码。

AGC 随包校验需同时对齐来源产物、目标与 feature 档位

  • 根因:AGC 新 worktree 不含 gitignored 的随包资源与源码侧 prepared 产物。validate_staged_plugins 从源码目录枚举 allowed 集合,缺目录时得到空集,即使 cargo 已启用编辑器 feature,现有 staging 文件也会被报成“未声明”。
  • feature 分支:allowed 还依赖 TARGET 与当前 CARGO_FEATURE_*;默认 features 为空。为编辑器档位准备的 staging 被 featureless Cargo 消费时同样报未声明,不能只凭文件存在判完整。
  • 处理:按 package-layout.json 的来源声明与目标/feature 组合准备资源,同时补齐源码侧 prepared 产物;featureless 消费方使用 npm run agc:bundled-resources:prepare -- --features=,编辑器消费方显式准备并传同一 feature 组合。不要复制不明来源产物来掩盖缺声明或来源缺失。
  • 缓存边界:resources/** 不是 build.rs rerun 输入,Cargo 缓存命中可能整段跳过校验。用 agc:bundled-resources:check 直接校验布局;需要证明 Cargo 路径实际校验时触发 build.rs 重跑。共享树变更 staging 档位前确认无并发消费者;原子替换撞目录句柄报 EPERM 时,停止占用后重试,不把残留 staging 当正式资源。
  • 关联:apps/ai-game-creator-shell/src-tauri/build.rs、src-tauri/build_support/package_layout.rs、package-layout.json、scripts/prepare-bundled-resources.mjs、scripts/check-generated-bindings.mjs;资源准备的构建隔离边界见本文件「build.rs 准备随包资源会污染不打包的 Rust 构建」条。

2026-10-05 把整个 THREE 命名空间塞进预览句柄会让 tree-shaking 失效

D

  • 现象:three 项目的运行画面点选句柄写成 { engine: 'three', THREE, scene, camera, renderer } 时,同一个 Vite 构建的产物从 517,682 B 涨到 730,831 B(+213,149 B ≈ +41%)。
  • 原因:句柄引用整个 import * as THREE 命名空间,打包器无法证明未使用的导出可以剪掉;预览桥实际只用 Raycaster / Vector2 / Vector3 / Box3 四个构造器。
  • 结论(现行口径):句柄用瘦身形态 three: { Raycaster, Vector2, Vector3, Box3 };该字段只给预览桥用,玩法代码不要引用它。旧形态仍被兼容读取(两种形态都有用例锁定),但新代码与模板一律用瘦身形态。

2026-10-04 AGC 渠道更新按固定主程序名互相查杀

  • 现象:开发版和 release 同时运行时,更新其中一个渠道会把另一个进程一起结束;release 从旧产品名升级后,旧 陶泥儿 Release.lnk 仍指向旧安装目录,更新后的 release 没有可用快捷方式。
  • 原因:Tauri 2.11 的 NSIS CheckIfAppIsRunning / KillProcess 只按 MAINBINARYNAME 匹配。所有渠道都使用 genarrative-ai-game-creator-shell.exe,所以插件无法按安装目录区分进程;更新模式还会跳过快捷方式创建,产品名变化后旧图标不会自动迁移。
  • 处理:channel-identity.mjs 新增 resolveChannelMainBinaryName:dev 保留旧文件名,release 与其它渠道使用后缀文件名;createChannelConfig 同批注入 Tauri mainBinaryName。release Windows 包使用独立 release-installer-hooks.nsh,从旧 陶泥儿 Release 卸载项恢复安装目录,迁移旧快捷方式并删除旧主程序/卸载项;dev 的历史改名钩子保持不变。
  • 不要踩的坑:只改 productName 或 identifier 不能阻止 NSIS 互相查杀;只改安装包文件名也不能让 updater 选中正确的进程,必须把 mainBinaryName 写入构建期 Tauri 配置,并保证 dev 的历史文件名不变。NSIS 钩子仍只能在 !macro 内引用模板常量/插件,且文件必须 UTF-8 with BOM。
  • 判据/验证:node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs 覆盖渠道主程序名、release 钩子与宏位置;node apps/ai-game-creator-shell/scripts/check-config.mjs 校验基线;真实 Windows NSIS 编译与 dev/release 同机更新 smoke 仍需发布环境执行。
  • 关联:apps/ai-game-creator-shell/scripts/channel-identity.mjs、apps/ai-game-creator-shell/scripts/build-release.mjs、apps/ai-game-creator-shell/src-tauri/windows/{installer-hooks.nsh,release-installer-hooks.nsh}、docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md。

2026-10-01 Rust 分片编译失败只剩汇总错误

  • 原因:--message-format=json 把编译诊断写到 stdout;只读取 compiler-artifact 的运行器会丢弃 compiler-message,CI 只能看到「due to 1 previous error」。
  • 处理:使用 json-render-diagnostics 保留 JSON artifact,同时让 Cargo 将诊断渲染到 stderr;运行器继承 stderr。既有分片 fixture 同时覆盖编译失败详情、测试 panic 和成功摘要,见开发运维文档的 Rust lane 口径。
  • 排查边界:函数签名变更与另一分支新增测试可能在无文本冲突的合并后产生参数不匹配;合并后必须检查测试目标,不能只验证普通二进制。

2026-10-01 线上 Nginx 手工维护漂移:/profile 刷新 404、发行包请求体仍限 64m

  • 现象:登录后刷新 https://www.genarrative.world/profile 直接 404,返回 Nginx 默认 404 页(162 字节);应用内点击进入正常。curl 复现:/、/project、/creation、/components、/design-system、/games*、/editor/canvas 全部 200 且正文与 / 同一份 SPA 外壳,只有 /profile(含 /PROFILE、/profile/)404。
  • 原因:刷新是真实 HTTP 请求,命中线上 /etc/nginx/conf.d/genarrative.conf 的 SPA allowlist;线上白名单是手工维护的(留档 genarrative.conf.bak-sparoutes-20260923T153751Z),profile 从未加入,该深链落回 location / 的 try_files $uri $uri/ =404。仓库三份模板当时都写了 profile,而 npm run check:nginx-spa-routes 只校验仓库模板,查不出线上漂移。同一份线上文件还把 client_max_body_size 停在 64m(模板 210m,会让 200 MiB 发行包上传 413),并保有 4 处模板没有的 host-only 块(画廊读取限流 location、/finance-forecast/、/medical-science/、/home/ 官网首页入口),所以直接拿模板整文件覆盖会删掉这些线上能力。
  • 处理:线上只收敛 SPA allowlist(12 条,含 /profile)并补 error_page 404 /404.html;host-only 块收进 deploy/nginx/snippets/genarrative-host-extras.conf,由主模板 include、由 Genarrative-Server-Provision 安装。退役路由不进白名单,也不需要显式 404 配置,落 location / 即得品牌 404(非 HTML 客户端保持纯 404)。
  • 长期口径:任何 SPA/路由白名单改动都要同时核对生效配置(sudo nginx -T)与仓库模板;线上主站 vhost 只由模板 + genarrative-host-extras.conf 生成,不再手工追加路由。snippet 内的 location 不在 Pingora 路由矩阵覆盖范围,Pingora 接公网 443 前要单独确认这些 host-only 路径。

build.rs 准备随包资源会污染不打包的 Rust 构建

  • 触发与机制:在 build.rs staging 会让未安装 npm 的 Rust lane 报 SDK 缺失;把平台专属 resources/** 写入基线 tauri.conf.json,Tauri 也会跨平台检查不存在的资源。
  • 处理:资源在 tauri dev|build 前由准备脚本生成,build.rs 只读校验;平台映射放各平台配置。不要用占位二进制或下载非目标资源绕过。准备步骤按单元整目录原子替换并拒绝白名单外残留,避免旧根路径与新 bin/ 路径并存而误判版本。
  • 校验边界:origin: source 比较源码字节/摘要并检查链接;prepared 产物至少验存在,手改字节不保证被拒,必须修改来源或准备声明。Godot 与 Codex 继续使用各自专门校验。
  • 关联:src-tauri/build_support/package-layout.json、scripts/prepare-bundled-resources.mjs;声明与生成口径见随包资源里程碑。

2026-09-29 NSIS 安装钩子被 include 在模板常量与插件目录之前(Jenkins 打包在 makensis 处中断)

  • 现象:Genarrative-Agc-Windows-Build 打 dev 渠道 Windows 包时,Rust 编译过了,makensis 却报 Plugin not found, cannot call nsis_tauri_utils::KillProcess、!include: error in script: "...\installer-hooks.nsh" on line 68、Error in script "...\installer.nsi" on line 28 -- aborting creation process,Tauri 最后只补一句 failed to bundle project 'The system cannot find the file specified. (os error 2)',退出码 1。
  • 原因:Tauri 2.11 的 NSIS 模板把钩子放在 installer.nsi 第 28 行({{#if installer_hooks}} !include "..." {{/if}},紧跟 StrFunc.nsh),早于 !define INSTALLMODE(第 37 行)与 !define MAINBINARYNAME(第 44 行),更早于第 89 行的 !addplugindir "${ADDITIONALPLUGINSPATH}"。钩子文件的第一版是顶层 Function,于是三件事同时踩雷:① include 时 ${INSTALLMODE} 还是空值,!if "${INSTALLMODE}" == "currentUser" 走 !else 分支——编出来的正是报错里的第 68 行 nsis_tauri_utils::KillProcess;② 插件目录此时还没 add,makensis 找不到 nsis_tauri_utils.dll,直接中断整个打包;③ 同一函数体里的 ${MAINBINARYNAME} / ${PRODUCTNAME} / ${MANUFACTURER} 也未定义(makensis warning 6000,字符串被静默替换成空文本)。只有第 29–94 行的顶层 Function 会这样,因为它跟着 include 立刻编译;!macro 体是插入时才展开的,所以放进宏里就没问题。
  • 处理(现行口径):installer-hooks.nsh 顶层只允许 !define / Var / !macro;可执行逻辑全部待在宏里——迁移逻辑是 !macro AgcMigrateLegacyIdentity,旧展示名改用运行期变量 $AgcLegacyIdentity 传入(原来的 Push/Pop + Function 形态在 section 上下文里既拿不到模板常量、也不能用 Return 提前返回),由 !macro NSIS_HOOK_POSTINSTALL 在模板插入点(Section Install,addplugindir 之后)展开。
  • 判据/取证:① apps/ai-game-creator-shell/scripts/build-release.test.mjs 新增守卫用例「安装钩子的可执行逻辑必须全在 !macro 内,顶层不得引用模板常量或插件」——对修复前的钩子文件跑同一解析逻辑命中 36 条顶层违规(含「第 68 行 顶层调用插件」),修复后 0 条;② 上面那份模板复现 + 双 INSTALLMODE 形态编译;③ 探针安装器(探针专属旧身份名 + 探针注册表键,全程不碰真实安装)实跑四组:旧目录不存在 → 卸载项与厂商键一个不动;旧目录 + 指向旧 exe 的桌面/开始菜单图标 → 旧目录、旧图标、旧卸载项被清,开始菜单图标补建、桌面图标只在原来就有时补建;桌面同名图标指向 notepad.exe → 图标保留且不补建桌面图标。
  • 不要踩的坑:① 钩子顶层任何"本地看着能编译"的可执行逻辑都可能在构建机上炸掉整条发布,改钩子后必须至少跑一次真实 NSIS 打包或上面那条守卫用例;② 顶层引用 ${INSTALLMODE} 这类模板常量不会报错,只会静默走错分支;③ 模板的 ${MAINBINARYNAME} 是不带 .exe 的名字(模板自己拼 .exe),钩子里不要再加一次扩展名;④ 钩子文件含中文身份字面量,必须存成 UTF-8 with BOM。
  • 关联:apps/ai-game-creator-shell/src-tauri/windows/installer-hooks.nsh、apps/ai-game-creator-shell/scripts/build-release.mjs、apps/ai-game-creator-shell/scripts/build-release.test.mjs、docs/project-memory/plans/【里程碑】AGC渠道安装身份隔离-2026-09-21.md。

2026-09-29 dev 渠道改名后,更新路径不会重建快捷方式(旧图标一直指向旧安装)

  • 原因:Tauri 的 NSIS 模板在更新模式(/UPDATE)与 /NS 下不创建快捷方式,只会把「同名」快捷方式的目标改回主程序名(installer.nsi 里的 CreateOrUpdate{StartMenu,Desktop}Shortcut,两者都在 $UpdateMode = 1 时 Return)。dev 渠道展示名改过两次(Genarrative AI Game Creator → 陶泥儿 → 陶泥儿开发版)而 identifier 保持不变,换成新名后旧快捷方式既不会被改写也不会被替代,旧安装目录同样留在原地;旧 exe 的更新器又指向同一份 latest.json,于是变成「点旧快捷方式 → 更新 → 自动启动新版」的循环。本机取证:E:\Desktop\陶泥儿.lnk 与开始菜单 陶泥儿.lnk 的目标都是 %LOCALAPPDATA%\陶泥儿\genarrative-ai-game-creator-shell.exe,全盘不存在 陶泥儿开发版.lnk。
  • 处理(现行口径):dev 渠道的 Windows 包注入 NSIS installer hooks(apps/ai-game-creator-shell/src-tauri/windows/installer-hooks.nsh;由 createChannelConfig 只在 channel === 'dev' 且目标含 windows 时下发 bundle.windows.nsis.installerHooks)。安装结束时:按旧展示名清理旧快捷方式(先用 IsShortcutTarget 校验目标命中旧安装目录)、旧安装目录与旧卸载项;用户原本就有旧桌面图标时按当前身份补建桌面图标,开始菜单图标始终补建。以后再改展示名,必须往钩子的旧身份表里追加旧名,否则那批机器升级后旧快捷方式继续指向旧安装。
  • 不要踩的坑:① 其它渠道不能注入这份钩子——旧名表属于 dev 的改名史,注进去等于删别人的安装;② 旧快捷方式必须先校验目标再删,不能按文件名裸删;③ 当前安装目录 $INSTDIR 永不进入删除路径;④ 只在检测到旧身份时补建图标,不要替用户新增桌面图标;⑤ 钩子文件里有中文身份字面量,必须存成 UTF-8 with BOM,否则 makensis 按 ANSI 解码会得到错误的旧安装路径。
  • 判据/取证:node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs(新增「dev 渠道的 Windows 包注入改名迁移钩子,其它渠道与目标不注入」用例,含钩子文件存在性、旧身份表完整性、JSON Merge Patch 后钩子不被资源映射顶掉)。本机真实安装状态见 docs/project-memory/plans/【里程碑】AGC渠道安装身份隔离-2026-09-21.md 的 2026-09-29 核对小节。
  • 关联:apps/ai-game-creator-shell/src-tauri/windows/installer-hooks.nsh、apps/ai-game-creator-shell/scripts/build-release.mjs、apps/ai-game-creator-shell/scripts/build-release.test.mjs、apps/ai-game-creator-shell/src-tauri/tauri.conf.json(productName = 陶泥儿开发版)、apps/ai-game-creator-shell/scripts/channel-identity.mjs。

2026-09-29 历史 release 与 Jenkins 构建暂存必须显式设置保留上限

  • 原因:production-api-deploy.sh 用 cp(不是 mv)把 ${SOURCE_DIR} 拷进 ${RELEASE_ROOT}/${VERSION},所以 Jenkins 工作区里的 build/<version>/ 永远留着;dev 上构建 agent 与发布目标机是同一台机器,同一份产物在盘上存在两份。脚本和 Job 两侧都没有保留上限,按小时级 dev 发布节奏约 1G/天。
  • 处理:两侧都补了保留上限。服务端:production-api-deploy.sh 新增 --keep-releases(默认 2),发布成功后保留 current 目标与最近 1 个历史 release,只删除同时含 api-server 或 web 标记的旧目录,清理失败只告警、不改变发布结论。CI 侧:Genarrative-Api-Deploy / Genarrative-Web-Deploy / Genarrative-Stdb-Module-Publish 在各自部署 / 发布步骤成功后只保留最近 2 个 build/<version>/,失败时不清理以便诊断和重跑。npm run check:production-api-deploy 增加默认值、显式值和非法值三类夹具,npm run check:production-ops 增加对应合同。
  • 不要踩的坑:直接按 mtime 排序删除会连带删掉发布根目录下不属于发布产物的目录(例如 dev-mcp-host-*),必须用 api-server/web 标记筛选;脚本里的 mv -T、find -printf 都是 GNU 语义,npm run check:production-api-deploy 需要 sha256sum 和 /usr/bin/cp,Windows 本地跑不了,只在 Linux CI / Linux 检出上有效(本地最低限度用 bash -n + npm run check:production-ops)。
  • 写 Jenkins 内联 shell 的两个坑:① Groovy 会处理 sh '''…''' / sh """…""" 里的反斜杠转义——\n 到 shell 手上会变成真实换行(Jenkinsfile.production-stdb-module-build 里必须写 printf "\\r" 就是同一件事),所以内联片段要么完全不用 \,要么写 \\;""" 是 GString,shell 变量必须写 \$name,而 ''' 不插值、保持 ${name}。② set -euo pipefail 下 ls build/*/ 在 glob 不匹配时会因 pipefail 把整个部署步骤判失败(实测:build/ 为空时清理步骤会把一次成功发布判成失败),必须用 if [ -d build ] 守卫 + || true 兜底。
  • GString 里的命令替换同样要转义:sh """…""" 内的 shell 命令替换必须写 \$(...)。写成裸 $(...) 时 Jenkins/Groovy 会在加载 Jenkinsfile 时直接报 illegal string body character after dollar sign,构建不会进入任何 stage;npm run check:production-ops 现已钉住 Stdb Publish 的暂存清理命令。
  • 关联:scripts/deploy/production-api-deploy.sh、scripts/check-production-api-deploy.mjs、scripts/check-production-ops-guardrails.mjs、jenkins/Jenkinsfile.production-api-deploy、jenkins/Jenkinsfile.production-web-deploy、jenkins/Jenkinsfile.production-stdb-module-publish。

开发机全局 Codex 会掩盖 macOS 包内依赖缺失

  • 症状与机制:DMG 生成成功、开发机能启动 Codex,仍可能是全局 PATH 代替了缺失的 staging 或 Tauri resource;包在干净机器上才失败。
  • 处理:复制 .app 到仓库外,在受限 PATH、隔离 HOME 下由正式资源查找完成组件摘要、app-server 握手和缺组件拒绝检查。包内容按 macos-bundle-policy.mjs 白名单检查,不把全部 node_modules 一概放行或拒绝,也不复制 Windows EXE/DLL 冒充 macOS 支持。
  • 边界:插件文件随包不代表不需外部工具链;插件 JS 入口仍需系统 Node,真实登录/Provider、GUI 和 Cocos 原生桥接另行验收。
  • 关联:docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md、scripts/check-macos-bundle.mjs。

2026-09-29 dev 上的 JNLP inbound agent 是历史残留,会在死端口上无限重连刷爆 syslog

  • 现象:dev 的 /var/log/syslog 约 250MB/天,内容是 jenkins-inbound-agent-start[pid] 反复输出指向 http://127.0.0.1:18080/tcpSlaveAgentListener/ 的 Connection refused 完整栈(2.6 天 61 万行,其中 genarrative-release-deploy-01 占 53 万行)。
  • 原因:/etc/jenkins-agent/*.env 里 JENKINS_URL=http://127.0.0.1:18080/,而该机只有 station 反向 ssh 提供的 18085/18086/18087,没有 18080。真实部署通道是 SSH launcher(java -jar remoting.jar -workDir /root/jenkins-agent-build,sshd 会话子进程),Jenkins 工作区、current 切换和当日 01:18 的发布都由它完成;两个 JNLP 单元从未连上(release-deploy-01 的 workdir 自 2026-05-09 起没有任何 workspace)。
  • 处理:停止并 disable jenkins-agent@genarrative-build-01.service 与 jenkins-agent@genarrative-release-deploy-01.service;观测窗口内 Failed to connect 新增归零,genarrative-api / spacetimedb / worker / pingora 保持 active。回滚用 systemctl enable --now jenkins-agent@<name>.service。
  • 不要踩的坑:ss -t 在没有状态过滤时不显示 LISTEN,连接被瞬拒时也抓不到 TCP 连接,不能用它判断 agent 是否在线;要看 journalctl -u jenkins-agent@<name>、ss -tlnp 的端口和 Jenkins 工作区归属。停 JNLP 单元前先确认控制器 slaveAgentPort=-1 且 SSH launcher 仍在跑,避免把唯一通道停掉。
  • 关联:/etc/systemd/system/jenkins-agent@.service、/etc/jenkins-agent/*.env、scripts/deploy/install-jenkins-inbound-agent.sh。

SPA allowlist 不能吞掉游戏发行网关路径

  • 触发与机制:前端、三份 Nginx 模板与 Pingora 的 SPA allowlist 必须同批更新;/games/game_<32位小写十六进制id>/… 是发行网关路径,若被 SPA fallback 吞掉,在线游玩入口会变成页面路由。
  • 处理边界:该路径必须进入 ReleaseGateway,重写上游并清空 Cookie,不进 SPA fallback、不套 SPA 限流,也不受维护闸拦截。用路由 parity 与 gateway smoke 核对真实路径形状,不放行形状不符的邻居。
  • 验证陷阱:改网关源码后不得用 --skip-build 验证,会拿旧二进制得到假 404;OpenSSL 默认配置缺失时,将 OPENSSL_CONF 指向实际存在的配置文件。路由登记与门禁接线见开发运维/Pingora 专题。

2026-09-29 随包资源的编译产物摘要不可复现,且准备步骤与应用构建共用同一输出路径

  • 摘要不可复现:同一 source / feature / profile / target 连续构建的 cocos-editor-bridge payload 摘要不同(除 PE TimeDateStamp 外还有 RSDS GUID 等 22 字节差异),所以「与迁移前逐项一致」只能对源码派生物(JS/HTML/JSON/license/notice,逐字节比对)、.NET publish 产物(Unity helper 跨两次重新发布逐字节一致)和命中工具链内部缓存的产物(Godot 走 buildId 早退,不重链)成立。核对打包一致性时不要用编译产物的 sha256 判回归,改比路径集合 + 导出面(DllMain、cocos_editor_bridge_bootstrap_source)+ 源码派生物摘要。
  • 共用输出路径:准备步骤的 cocos-bridge-build 用 cargo build -p cocos-editor-bridge --features windows-injection,而应用构建带的是 windows-bootstrap + windows-injection(cocos-editor-injection 的闭包),两个单元写同一个 target/<triple>/<profile>/deps/cocos_editor_bridge.dll。后构建的单元覆盖先构建的产物时,准备步骤的候选查找会取到「上一次遗留的另一个单元」,交替构建还会多一次重链。要改就从这里改:让准备步骤用独立 target 目录,或与应用的 feature 集对齐。
  • 验证方式:runTauriBuild(scripts/build-release.mjs)+ --bundles nsis,再 7z x 解包比 plugins/**;准备步骤连续三次复跑要求 resources/plugins 的 32 个文件内容与 mtime 全不变。

旧版 release 安装包文件名中的编码空格不能按控制字符拒绝

  • 现象:release 环境点击“下载客户端”只显示无法获取最新版本,GET /api/client-downloads 返回 502 UPSTREAM_ERROR;dev 环境正常。
  • 原因(历史):旧版 release 渠道产品名带英文渠道后缀,NSIS 首装包名因此包含空格,OSS latest.json 中的 URL 使用 %20。validate_download_url 的百分号解码校验把 0x20 当成控制字符拒绝,Windows 清单被判非法;release macOS 清单当时又未发布,聚合后两端都无下载项并返回 502。
  • 处理(现行口径):release 渠道现复用正式产品名 陶泥儿,不再生成带 Release 文本的包名;清单 URL 校验仍允许合法的百分号编码空格,以兼容自定义渠道产品名,同时继续拒绝其它控制字符、/、\、DEL、非法编码和跨目录文件名。
  • 验证:platform-oss 继续覆盖带编码空格的自定义渠道文件名,并用 陶泥儿_0.1.110_x64-setup.exe 做 release 清单解析回归;线上接口应返回 release Windows 下载项,macOS 未发布时进入 unavailablePlatforms。
  • 关联:server-rs/crates/platform-oss/src/client_downloads.rs、apps/ai-game-creator-shell/scripts/build-release.mjs、docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md。

2026-09-21 不同渠道的包体在同一台设备安装会互相顶掉

  • 现象:在一台已经装了某个渠道 AGC 客户端的设备上安装另一个渠道的安装包,装完后旧客户端直接消失(安装目录被覆盖、卸载项被接管),更新端点、平台服务器与本地登录态一起换成新渠道的;两个渠道的客户端无法共存。
  • 原因:渠道此前只烘焙了 plugins.updater.endpoints 与 VITE_AGC_PLATFORM_CHANNEL(apps/ai-game-creator-shell/scripts/build-release.mjs 的 createChannelConfig),productName / identifier 用的是渠道无关的基线值。Tauri 的 Windows 安装目录与卸载项由 productName 决定,WebView2 数据目录与客户端数据目录由 identifier 决定,于是所有渠道落到 %LOCALAPPDATA%\陶泥儿、HKCU\...\Uninstall\陶泥儿 与 %APPDATA%\world.genarrative.ai-game-creator。
  • 处理(现行口径):渠道进入安装身份,dev 保持既有开发版身份,release 复用正式产品名但保持独立 identifier,自定义渠道派生 <产品名> <渠道显示名> 与 <基线>.<渠道>;身份与端点在同一次构建期 --config 注入。见决策记录 2026-09-21 与 2026-09-30 条目。
  • 核对方式:装完任渠道的包后看 HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\<产品名> 的 InstallLocation、%APPDATA%\<identifier> 与主程序窗口标题是否按渠道分开;同名安装目录或同名数据目录说明身份没有生效。
  • 易错点:只改安装包文件名或快捷方式名而不改 identifier,两个渠道仍会抢同一份 Agent Runner / 项目锁与登录态;反过来把渠道后缀加在默认渠道上,既有安装的升级链会断(客户端认不出旧安装)。相似前缀目录(如 world.genarrative.ai-game-creator-backup)不得进入提权 ACL 的 managed 范围。
  • 关联:apps/ai-game-creator-shell/scripts/channel-identity.mjs、build-release.mjs、build-macos-ci.mjs、src-tauri/src/config.rs。

发布器守卫拒绝时不要把 process.exit 用在 fetch 句柄未关闭处

  • 现象:agc-template-library-publish.mjs --dry-run 撞上「同一 templateVersion 的 ZIP 不得变」门禁时,终端只剩一句 Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\win\async.c,看不到任何拒绝原因,看起来像脚本崩溃而不是被拒绝。
  • 原因:main().catch(...) 里直接 process.exit(1);此时 dry-run 刚用 fetch 读过公共清单,句柄仍在关闭流程中,Node/libuv 在 Windows 上先抛断言,把真实错误信息挤掉。
  • 处理:catch 里只设 process.exitCode = 1,让事件循环自然退出;同批把说明文档纳入受管发布(见决策记录 2026-09-21 条目)。
  • 验证:把模板源复制到临时目录、把某个模板的 templateVersion 改回与线上同版本并改动一个字节,--dry-run 应打印 同版本 ZIP 内容或尺寸变化,请递增 templateVersion 且退出码为 1,不再出现 Assertion failed。
  • 关联:scripts/agc-template-library-publish.mjs。

release 冷备空间不足会把生产留在维护态

Genarrative-Stdb-Module-Publish 先进入维护模式、停掉 API/controller/worker,再执行发布前冷备份;archive 口径要求 data × 1.1(40.6GiB 数据 → 44.7GiB,加上生产根盘只剩 13.5GiB),于是 2026-09-21 的 release 发布在停服后失败并保持维护态,站点 503 直到人工恢复。规则:空间预检必须先于 maintenance-on 与停服;archive 不足且未显式禁用降级时改用 files(max(data × 0.05, 2GiB),不落地本地归档,但 files 不支持 --defer-upload,会从 async 收敛为 sync);只有真正开始 spacetime publish 之后的失败才允许保持维护态。另:定时备份的失效锁在当前仓库版本会自动清理,但生产机 /var/lib/genarrative/backup-tools/database-backup-to-oss.mjs 若是旧版会拒绝抢锁并要求人工删锁,需随 provision 更新。

按上游 #5555 的 retention 语义,只有最近 retain-snapshots(默认 2)份 snapshot 与覆盖它之后的 commitlog 段是重启所需,其余历史可丢;因此备份改为 files + full + --minimal --retain-snapshots 2(release 实测 40G → 3.6G,热备不停服),不再做增量差异计算,也不需要 44.7G 冷备空间。

手工发布目标不会自动映射成 AGC 更新渠道

Genarrative-Manual-Build-And-Deploy 的 DEPLOY_TARGET=release 只控制 Stdb / API / Web 全量发布,不会自动成为 AGC 的 AGC_UPDATE_CHANNEL。2026-09-21 的手工发布 #10 就因此让 Windows #107 与 macOS #16 使用默认 dev,把 0.1.95 上传到 agc/dev-win、agc/dev-mac,而 agc/release-win/latest.json、agc/release-mac/latest.json 保持 404。现行口径:手工入口按 release -> release、development -> dev 同时给 Windows 与 macOS AGC Build 传 AGC_UPDATE_CHANNEL;补发已烧号的同一版本时用相同 AGC_RELEASE_VERSION 直接重跑两条 AGC Job,不重新发号。OSS 发布对象是 agc/<channel>-win|mac/,不存在 agc/release/ 这一层。

Jenkins Windows 节点的 PATH 白名单决定 Godot 原生扩展能否构建

Genarrative-Agc-Windows-Build 在阶段里用 AGC_WINDOWS_PATH 整体替换 PATH、不继承节点机器的 PATH,所以 Godot C++ 引导需要的 CMake 与 Python 必须显式写进这份白名单,装在机器 PATH 上并不生效。2026-09-21 的 #97–#99 连续失败都停在 Get-Command cmake.exe(#93–#96 是更早的手写 C ABI 在 MSVC C 模式下的对齐问题):节点只有 Visual Studio Build Tools(C:\BuildTools)自带的 CMake 3.31,缺 Python 3。修复后白名单包含 C:\BuildTools\Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin、C:\Python312、C:\Python312\Scripts,preflight 校验 CMake ≥3.25、Python 3 和 Visual Studio 17 2022 生成器;把 cmake.exe 单独复制到别的目录会丢掉 share/cmake-*/Modules,不能替代加入安装目录。新节点的 Python 用 python-3.12.10-amd64.exe /quiet InstallAllUsers=1 TargetDir=C:\Python312 PrependPath=1 Include_launcher=1 InstallLauncherAllUsers=1 静默安装即可,CMake 不必另装。

Tauri --no-sign 会连带跳过 updater 签名

AGC macOS 发布入口一度传入 --no-sign(目的是绕过没有 Apple 证书的代码签名),结果 Tauri 打印 Warn Updater signing is skipped due to --no-sign flag.,产物只有 *​.app.tar.gz 而没有 .sig,发布入口按设计在「缺少更新包签名」处失败关闭(2026-09-20 首次 Jenkins 实跑命中)。正确做法是不传 --no-sign,改为剥离 APPLE_* 凭据让 Tauri 跳过 Apple 签名——minisign 更新包签名与 Apple 代码签名这两个开关在 Tauri 里并不独立。Apple 签名状态要按 codesign -dv 实测记录,不能硬编码。

同一 CI 容器内并行 Rust 分片可能比串行更慢

  • 触发与机制:同一 job 中开多个分片进程不等于资源隔离:共享 HOME、target 与固定临时路径会互相争抢;同进程并发又可能交叉触发全局锁、static 与异步终态。
  • 处理:CI 在 job/lane 之间并发,lane 内顺序执行分片,片内保持 --test-threads=1 与独立 TMPDIR;分片名单须自校验并集等于 --list 全集且互斥,不能用调度优化换来漏测。
  • 关联:apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs;当前 job、依赖预热与 npm 安装口径见 development-workflow.md,不沿用历史分组和预热清单。

2026-09-14 根门禁的 [check:native-shells] <label> 是别处按字面量校验的契约

  • 现象:客户端 CI 拆成六个 job 后,Native shell tests 在 desktop-shell:typecheck 步骤红了:Error: root native shell gate must keep desktop release artifact check console.log('[check:native-shells] desktop-release-binary-artifact')。
  • 原因:拆分时给 scripts/check-native-shells.mjs 加了公共打印函数 runNativeShellGate(group, label, gate),把各门禁的 console.log('[check:native-shells] <label>') 换成了模板字符串。而 apps/desktop-shell/scripts/check-config.mjs 与 apps/mobile-shell/scripts/check-config.mjs 会读取根门禁脚本源码,对这类 label 做字面量断言(含 assertDesktopReleaseBinaryArtifact(); 调用本身),label 一变形断言就失败。
  • 处理:runNativeShellGate 收窄为 (group, gate),label 回到各门禁执行体里以字面量打印;分组能力与 --groups= 语义不变。脚本内已注明"不要把 label 抽成变量",外部壳的 check-config 是它的消费者。
  • 关联:scripts/check-native-shells.mjs(runNativeShellGate)、apps/desktop-shell/scripts/check-config.mjs、apps/mobile-shell/scripts/check-config.mjs。

2026-08-15 #[cfg(windows)] 里的代码不参与 Linux CI 编译,CI 绿不代表能构建

  • 现象:把 master(9f5c84ee7)合进 feat/five_min_design 后,cargo check --all-targets 在 Windows 上直接 error[E0658]: use of unstable library feature 'windows_by_handle',位置是 apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs 的 metadata.number_of_links()。该文件与 origin/master 逐字节相同,即 master 自身在 Windows 上就构建不过。
  • 原因:std::os::windows::fs::MetadataExt::number_of_links 至今未稳定(rust-lang#63010);当时 rust-toolchain.toml 锁的 stable 是 1.96.0,2026-09-20 升到 1.98.1 后在同一台 Windows 机器上用该 stable 实测仍报 error[E0658]: use of unstable library feature 'windows_by_handle'(见 decision-log 同日条),因此本条的处置口径不变。引入它的提交是 578f8019f(优化 AGC 项目入口并识别 Godot 工作区),其中 unix 分支用 MetadataExt::nlink()(已稳定)、windows 分支用了未稳定的对应物。Linux CI 上 #[cfg(windows)] 整块不参与编译,所以 CI 全绿。
  • 更普遍的形状:只要一段代码只在某个 #[cfg(target_os)] 下编译,它就完全绕过了其它平台的 CI——不只是 unstable feature,还包括类型错误、借用错误、缺失 import。跨平台分支是「双写」,两侧都得有人真的编译过。
  • 处理:本仓库对「文件是不是无硬链接普通文件」统一自行声明 ByHandleFileInformation 并调用 GetFileInformationByHandle,见 runner/endpoint.rs、tool_plan_handoff/storage_windows.rs、project/agent_db.rs、git_inspect.rs、image_inspect.rs、agent/generation/canvas_generation.rs。manifest.rs 当前已采用同一实现,并保留 fail-closed 语义:无法取得句柄信息或确认存在硬链接时均拒绝,同时拒绝 directory / reparse point。
  • 验证:改后 cargo check --offline --all-targets 通过、cargo fmt --check 通过、project::manifest 与 godot 相关定向测试 65 passed / 0 failed。判断「是不是本次合并引入」的通用手法:git diff origin/master -- <file> 为空即说明该文件就是 master 原样,问题不在合并。
  • 关联:apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs;rust-toolchain.toml;master 提交 578f8019f。

Jenkins 异步备份不能用 nohup 脱离作业

  • 现象:Stdb Publish 成功,上传日志只留下“已获取进程锁 / 上传已有备份 / 目标对象”,没有成功或可捕获错误;本地 tar.gz 和 uploadStatus=deferred manifest 每次发布后继续增长。
  • 原因:nohup 只忽略终端 HUP,不会移除 Jenkins/Hudson 进程 Cookie;Job 收尾可清理后台 uploader。原链路只上传当次归档,旧 deferred manifest 没有扫描重试,而 files-history timer 只处理 /stdb 历史文件。
  • 处理:发布退出时用独立 systemd-run --collect --service-type=exec transient unit 执行 --upload-deferred-dir,串行处理同库 deferred/pending 归档。启动前拒绝符号链接和非绝对路径;unit 启动失败必须保留 status、archive 和 manifest。补偿扫描不删除上传未验真的文件,也不扫描目录外路径。
  • 验证:门禁必须禁止 nohup,要求命名 transient unit、--collect、Type=exec 与失败后保留 status;备份测试覆盖稳定顺序、同库过滤、已上传但未清理的归档收敛、归档缺失报告与路径逃逸拒绝。现场最终核对 backup lock、manifest、transient unit/result、根盘、SpacetimeDB/API/worker/controller/Nginx 和公开端点。
  • 关联:scripts/deploy/production-stdb-publish.sh、scripts/database-backup-to-oss.mjs、scripts/check-production-ops-guardrails.mjs、scripts/check-database-backup-to-oss.mjs。

Vite 源码 CSS 清理插件必须早于 Tailwind 执行

  • 现象:生产构建通过,但本地 dev 打开主站后全页白屏,/src/index.css 返回 500,Vite 报 Unknown word updateStyle。
  • 原因:自定义 CSS 插件使用 enforce: 'post',在 Tailwind/Vite 已把 CSS 转为包含 updateStyle import 的 JavaScript 模块后,仍调用 postcss.parse。
  • 处理:需要改写原始 CSS 的 transform 固定使用 enforce: 'pre';最终构建产物清理继续放在 generateBundle,不要混用两个阶段的输入格式。
  • 验证:真实启动 npm run dev 后请求 /src/index.css 必须返回 200,并在浏览器确认 #root 已挂载且控制台无 CSS transform 错误。
  • 关联:vite.config.ts、scripts/vite-retired-css-plugin.test.ts。

API Build / Deploy 归档清单不能漏掉随包 Pingora 脚本

  • 现象:Genarrative-Api-Deploy 在发布阶段报 发布产物缺少 Pingora TLS 证书同步脚本: build/<version>/scripts/deploy/pingora-tls-cert-sync.mjs。
  • 原因:scripts/build-production-release.sh 已经把脚本复制进 build/<version>/scripts/deploy/,但 Jenkins API Build 的 archiveArtifacts 和 API Deploy 的 copyArtifacts 过滤清单仍可能漏掉新增随包脚本,导致 Deploy 工作区拿到的是残缺发布包。
  • 处理:新增随包部署脚本时,必须同时更新 jenkins/Jenkinsfile.production-api-build 的归档清单、jenkins/Jenkinsfile.production-api-deploy 的复制清单和 scripts/check-production-ops-guardrails.mjs 的字符串门禁;不要在 Deploy Job 里从工作区根目录或源码 checkout 兜底补脚本。
  • 验证:运行 npm run check:production-ops、npm run check:production-api-release 和 npm run check:production-api-deploy,确认构建包、Jenkins 归档链路和 deploy fail-fast 检查口径一致。
  • 关联:jenkins/Jenkinsfile.production-api-build、jenkins/Jenkinsfile.production-api-deploy、scripts/deploy/production-api-deploy.sh、scripts/check-production-ops-guardrails.mjs。

Pingora 直连 80/443 不能只改 env

  • 根因:shadow service 以非 root 用户运行且不授予 CAP_NET_BIND_SERVICE;只改低端口 env 不会解决 capability、服务用户证书可读性和 Nginx 占用 80/443。
  • 启用边界:current 发布包必须自包含二进制、checksum、manifest、部署/巡检/证据脚本和模板。本轮执行入口与真实 systemd ExecStart 均应来自当前制品,不能用 Jenkins 工作区或旧 /etc 模板兜底。先审阅正式 runbook、核对证书同步后的私有目录权限和真实 Host/SNI,再通过随包自审、preflight 与 direct live;启用前端口应空闲,启用后应由 Pingora 占用。
  • 回退顺序:先用随包脚本恢复 Pingora shadow 高端口 env,清空 TLS/redirect listener 和证书/私钥字段;再把 health patrol 恢复为 Nginx 与切换前 public base URL/Host,随后执行回退脚本(先 nginx -t,再移除 capability、重启 shadow、reload Nginx)。以切换前真实 vhost URL与响应片段验证回退,不能只用 /healthz 或 HTTP 200。
  • 证据边界:三阶段状态与五条切换命令必须来自同一 cutoverRunId 和标准时间线,命令身份绑定 current 随包绝对路径;即时验真须同时检查 hash 与 summary OK。启用后保留 direct access-log 对账与静态头摘要,回退后证明 active env 为 shadow;旧包、缺摘要、CRITICAL 或混轮证据应重采,不手改 manifest。probe token 与其它敏感值不得进入输出或归档。
  • Bash 易错点:< <(...) 进程替换不会把生产者失败传播给消费循环。部署检查须先捕获并显式检查配置提取命令的退出状态,再解析输出,首错立即返回,避免失败后继续用空值制造矛盾误报。
  • 旧 current 例外:若 current 缺网关二进制,却残留 stale direct-entry drop-in,标准 rollback 会卡在固定重启 Pingora。只有确认网关不可执行、Pingora inactive、env 完整为 shadow、Nginx 配置及公网 smoke 正常后,才以单次 fail-fast 运维动作清理 stale drop-in、daemon-reload、复核 capability/DropInPaths 清空,再 reload/start Nginx并核对正式 vhost、API/SpacetimeDB与 nginx 模式 health patrol;不能伪造 shadow 存活验收。
  • 关联:完整参数、发布门禁、静态 Range/If-Range/压缩与证据字段见 docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md;直接脚本入口为 scripts/deploy/pingora-direct-enable.sh、pingora-direct-rollback.sh、production-api-deploy.sh 和 scripts/ops/pingora-cutover-*。

生产冷备份后 API 和外部生成 worker 不能只依赖 SpacetimeDB 自恢复

  • 现象:release 机器 03:20 冷备份后,spacetimedb.service 已恢复,但作品列表、创作入口配置或公开 gallery 继续超时 / 502 / 504,genarrative-api.service 保持 stopped;或图片画布生成请求返回队列态后长期显示排队,external_generation_job 有 claimable pending,但 genarrative-external-generation-worker@1.service / controller 是 inactive;也可能先看到 /var/lib/genarrative/database-backups 把根分区写满,gzip: stdout: No space left on device。
  • 原因:genarrative-api.service、genarrative-external-generation-worker@*.service 和 genarrative-external-generation-controller.service 都配置了 Requires=spacetimedb.service,冷备份停止 spacetimedb.service 时这些服务会被 systemd 依赖关系一并停止;如果备份脚本只在打包成功后重启依赖服务,那么 tar/gzip 因空间不足失败时就只会恢复数据库,外部生成队列和 API 仍无人接管。
  • 处理:生产冷备份 unit 和发布脚本必须带 --restart-service-after genarrative-api.service、--restart-service-after genarrative-external-generation-worker@1.service 和 --restart-service-after genarrative-external-generation-controller.service;备份脚本必须在停止 SpacetimeDB 前做工作目录剩余空间预检,并且一旦已经停过 SpacetimeDB,就算打包失败也要先恢复 SpacetimeDB 与这些依赖服务,再返回原始备份错误。genarrative-api.service 也保留对 controller 的 Wants 弱依赖,覆盖“只恢复 API”的现场兜底。仓库用 npm run check:production-ops 和 npm run check:database-backup 检查 systemd 模板、脚本失败路径、API build/deploy 归档和健康巡检链路。现场修复后执行 systemctl daemon-reload,但不要为了验证而手动触发冷备份。
  • 验证:systemctl cat genarrative-database-backup.service 应包含这些参数;systemctl is-active spacetimedb.service genarrative-api.service genarrative-external-generation-worker@1.service genarrative-external-generation-controller.service nginx.service 全为 active;curl -fsS http://127.0.0.1:3101/v1/ping、/healthz、/readyz 和代表性 /api/editor/showcase/resources 均成功;npm run check:database-backup 覆盖空间不足不触碰 systemctl、tar 失败仍恢复依赖服务;get_external_generation_queue_stats_and_return 不应长期出现 claimable pending。
  • 关联:deploy/systemd/genarrative-database-backup.service、scripts/database-backup-to-oss.mjs、scripts/ops/production-health-patrol.mjs、docs/【开发运维】本地开发验证与生产运维-2026-05-15.md。

Pingora canary 不能只看 handoff 响应头

  • 现象:目标 Nginx 前缀 canary 的 /__genarrative_pingora_canary/healthz 和代表性 API 都返回成功,响应也带 X-Genarrative-Nginx-Handoff: pingora-canary,但仍无法证明 Nginx 与 Pingora 对同一请求的 method/status/path 完全一致。
  • 原因:响应头只能证明请求经过了 canary snippet,不能证明同一 request_id 已在 Pingora access log 落盘,也不能发现 healthz exact location 映射、前缀 rewrite 后路径或状态码漂移。
  • 处理:本机 / CI 的 check-pingora-canary-docker 也必须写临时 Nginx access log,并在 live smoke 后复用 scripts/check-pingora-canary-access-log-parity.mjs 对账 Docker Nginx 与 Pingora access log。目标机 --require-live 必须在 live smoke 后继续执行同一脚本,默认读取 /var/log/nginx/genarrative.access.log 和 /var/log/genarrative/pingora-gateway.access.log,按 request_id 对照 /__genarrative_pingora_canary/healthz 与 /__genarrative_pingora_canary/api/creation-entry/config。Nginx canary exact /healthz 映射到 Pingora shadow /__genarrative_pingora/healthz,其它 canary 前缀路径按 rewrite 后路径比对。对账脚本的日志路径、prefix、必需路径和 --since-lines / GENARRATIVE_PINGORA_CANARY_ACCESS_LOG_SINCE_LINES 不能包含换行或 NUL;日志行里解析出的 URI / path 含控制字符时也必须失败,避免污染值进入 JSON 对账输出。
  • 验证:本机或 CI 执行 node scripts/check-pingora-canary-docker.mjs --require-docker --pull 时应同时完成临时 Nginx / Pingora access log 对账。目标机执行 node scripts/check-pingora-release-readiness.mjs --require-docker --pull-docker --require-nginx --require-live --live-base-url http://127.0.0.1 --live-host <域名> --live-nginx-access-log /var/log/nginx/genarrative.access.log --live-pingora-access-log /var/log/genarrative/pingora-gateway.access.log;本机执行 npm run check:pingora-release-readiness-plan 和 npm run check:production-ops,确认 live 门禁计划包含真实 access log 对账。
  • 关联:scripts/check-pingora-release-readiness.mjs、scripts/check-pingora-canary-access-log-parity.mjs、deploy/env/pingora-canary-live.env.example、docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md。

Pingora realpath canary include 要晚于 log_format

  • 现象:目标机把 genarrative-pingora-realpath-canary.conf 放进 /etc/nginx/conf.d/ 后,nginx -t 失败并报 unknown log format "genarrative_upstream"。
  • 原因:真实路径 canary 是独立 server 片段,并使用 access_log /var/log/nginx/genarrative-pingora-realpath-canary.access.log genarrative_upstream;。Nginx 会按文件名顺序加载 conf.d;如果 canary 文件名早于定义 log_format genarrative_upstream 的主站配置,access log 行会先被解析而找不到格式。
  • 处理:真实路径 canary 启停统一用 current release 随包脚本,不再手工写 /etc/nginx/conf.d/。启用执行 /opt/genarrative/current/scripts/deploy/pingora-realpath-canary-enable.sh --apply --probe-token <token> --host <域名> --base-url http://127.0.0.1:18083,脚本固定写入晚于主站配置加载的 /etc/nginx/conf.d/zz-genarrative-pingora-realpath-canary.conf,并在 nginx -t、reload 或 live smoke 失败时恢复写入前配置。关闭执行 /opt/genarrative/current/scripts/deploy/pingora-realpath-canary-disable.sh --apply,脚本在 nginx -t 或 reload 失败时恢复删除前配置。另一种长期做法是把 log_format 放到所有 conf.d server 之前的全局 Nginx 配置。检查配置时不要把 probe token 原文写入记录。
  • 验证:提交前运行 npm run check:pingora-realpath-canary-toggle 或默认聚合门禁 npm run check:pingora-release-readiness,确认启停脚本的 dry-run、apply、失败回滚和 disable 恢复逻辑仍被覆盖。启用脚本通过后,再运行 node -- /opt/genarrative/current/scripts/check-pingora-canary-live.mjs --realpath --base-url http://127.0.0.1:18083 --host <域名>、node -- /opt/genarrative/current/scripts/check-pingora-canary-access-log-parity.mjs --realpath --nginx-log-file /var/log/nginx/genarrative-pingora-realpath-canary.access.log --pingora-log-file /var/log/genarrative/pingora-gateway.access.log ... 和 node -- /opt/genarrative/current/scripts/check-pingora-release-readiness.mjs --release-runtime-only --require-realpath-live ...。若只启用了真实路径 canary,不要同时传 --require-live,否则前缀 canary 未启用时会按正式 Nginx HTTP 入口返回 301。
  • 关联:deploy/nginx/snippets/genarrative-pingora-realpath-canary.conf、deploy/nginx/README.md、docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md、scripts/check-pingora-release-readiness.mjs。

current release 不能运行依赖源码工具链的 readiness

  • 症状与机制:在 /opt/genarrative/current 运行默认 release readiness 会要求 Cargo/npm/Docker 等构建环境;调用源码 checkout 的脚本还可能验证另一份发布内容。
  • 处理:切换前后都调用 current release 随包的 scripts/check-pingora-release-readiness.mjs --release-runtime-only,使用包内可自包含的检查。聚合脚本及子脚本必须通过 API release、Build 归档、Deploy 复制清单完整交付,缺失不能回退 Jenkins workspace。
  • 关联:docs/【开发运维】本地开发验证与生产运维-2026-05-15.md 的 Pingora current release 门禁。

Jenkinsfile 开头不能带 UTF-8 BOM

  • 现象:Genarrative-Stdb-Module-Publish 在 Pipeline script from SCM 读取 jenkins/Jenkinsfile.production-stdb-module-publish 后,流水线还未进入任何 stage 就失败,报 java.lang.NoSuchMethodError: No such DSL method 'pipeline',堆栈位置是 WorkflowScript.run(WorkflowScript:1)。
  • 原因:该 Jenkinsfile 文件前三字节是 UTF-8 BOM EF BB BF,Jenkins/Groovy 把它拼进首个标识符,导致实际调用的是 \ufeffpipeline 而不是 Declarative Pipeline 的 pipeline 全局。
  • 处理:仓库内 jenkins/Jenkinsfile.production-* 保存为 UTF-8 without BOM;不要为了解决 Windows PowerShell 5.1 .ps1 中文解析问题而给 Jenkinsfile 本身加 BOM。只有 Jenkins helper 临时写出的 .ps1 才按需要转成 UTF-8 with BOM。
  • 验证:检查 jenkins/Jenkinsfile.production-stdb-module-publish 文件开头字节不再是 EF BB BF,并用 Jenkins validateDeclarativePipeline 或重放 Genarrative-Stdb-Module-Publish,不应再停在 No such DSL method 'pipeline'。
  • 关联:jenkins/Jenkinsfile.production-stdb-module-publish、docs/【开发运维】本地开发验证与生产运维-2026-05-15.md。

Full Build 的维护退出节点不得 checkout Git

  • 现象:Full Build 的 Stdb、API 和 Web 都已发布成功,Exit Maintenance 进入目标部署 agent 后却先执行 checkout scm,用 ssh://git@127.0.0.1:2222/... 拉仓库并报 Connection refused,导致已部署的维护退出脚本根本没有执行。
  • 原因:127.0.0.1:2222 只是 Jenkins controller 上的 Gitea SSH 端口,在部署 agent 上代表部署机自身。该次流水线在 Jenkins 重启后恢复,Declarative 的阶段 agent 路径未继续遵守顶层 skipDefaultCheckout(true),在 steps 前注入了不必要的 SCM checkout。
  • 处理:Exit Maintenance 保持 agent none,在 steps 内根据 DEPLOY_TARGET 用显式 node(deployLabel) 分配目标机,只从绝对路径执行 current release 已携带的 /opt/genarrative/current/scripts/deploy/maintenance-off.sh。不要在这个节点添加 GitSCM、Git SSH 凭据或 Jenkins workspace 相对路径。
  • 验证:运行 npm run check:production-ops;重放流水线时,Exit Maintenance 的 Running on <deploy-agent> 之后应直接进入 sh,不应出现 checkout、GitSCM 或 Git 凭据日志。
  • 关联:jenkins/Jenkinsfile.production-full-build-and-deploy、scripts/check-production-ops-guardrails.mjs、docs/【开发运维】本地开发验证与生产运维-2026-05-15.md。

release tracking outbox 权限错误先查 env 缺失

  • 现象:release 机器 journalctl -u genarrative-api.service 每秒刷 tracking outbox 定时封存 active 文件失败 error=Permission denied (os error 13) 和 tracking outbox 批量写入 SpacetimeDB 失败。
  • 原因:旧 /etc/genarrative/api-server.env 没有 GENARRATIVE_TRACKING_OUTBOX_DIR 时,api-server 会回退到本地开发默认相对路径 server-rs/.data/tracking-outbox;systemd 工作目录是只读发布目录 /opt/genarrative/releases/<version>,genarrative 用户不能在其中创建 server-rs。
  • 处理:补齐 GENARRATIVE_TRACKING_OUTBOX_DIR=/var/lib/genarrative/tracking-outbox 及 batch/flush/max 配置,创建并授权 /var/lib/genarrative/tracking-outbox 给 genarrative:genarrative,再重启 genarrative-api.service。Server-Provision 与 API-Deploy 会保留旧 env 但自动补缺这些运行态路径。
  • 验证:tr '\0' '\n' < /proc/$(systemctl show genarrative-api.service -p MainPID --value)/environ | grep GENARRATIVE_TRACKING_OUTBOX_DIR 应指向 /var/lib/genarrative/tracking-outbox;重启后当前 PID 不再出现 Permission denied (os error 13)。
  • 关联:scripts/deploy/production-api-deploy.sh、scripts/jenkins-server-provision.sh、docs/【开发运维】本地开发验证与生产运维-2026-05-15.md。

恢复 Persistent 备份 timer 可能立即补跑白天冷备

  • 触发与机制:备份 timer 虽 enabled 却 inactive/dead、NEXT 为空时,直接重启 Persistent timer 可能补跑已错过的窗口,立即停止 SpacetimeDB。otelcol 的 217/USER 是另一条服务用户/配置缺失问题,不能混为同一故障。
  • 处理:修备份 timer 前先 touch /var/lib/systemd/timers/stamp-genarrative-database-backup.timer,再 daemon-reload/start,确认下一触发是预期冷备窗口;不要为确认状态随手触发冷备。otelcol 用户与配置按 provision 运维说明补齐后单独验活。
  • 关联:docs/【开发运维】本地开发验证与生产运维-2026-05-15.md、deploy/systemd/genarrative-database-backup.timer。

Vite 构建完成后仍判红,先检查 build-gate 的 warning 汇总

  • 根因:主站与后台输出 built in ... 后,scripts/build-gate.mjs 仍会收集 stdout/stderr 中的 warning 并硬失败;看到 Build gate failed because warnings were emitted 应按警告原文定位,不能直接当成 Rust / SpacetimeDB 编译错误。
  • 产物体积警告:chunk 超过 vite.config.ts 或 apps/admin-web/vite.config.ts 的 chunkSizeWarningLimit 时,应判断是否需要真实拆包或调整合理阈值,再跑 npm run build。
  • 代理环境警告:NODE_USE_ENV_PROXY=1 配合代理变量会触发 [UNDICI-EHPA] Warning: EnvHttpProxyAgent is experimental;当前门禁只忽略含 ExperimentalWarning 的行,覆盖不到它。本地可清空 NODE_USE_ENV_PROXY、HTTP_PROXY / http_proxy、HTTPS_PROXY / https_proxy、ALL_PROXY 后验证;要支持带代理运行,须明确修改门禁忽略规则,不能把编译成功当成门禁已通过。
  • 验证:重新执行 npm run build,确认主站与后台构建完成且没有 warning 汇总。

Rust 构建不要让不可用的 sccache 阻断 rustc

  • 现象: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 在 Windows 下限时执行真实 wrapper 探测 sccache rustc -vV,成功才启用 sccache,缺少命令、daemon 启动超时或 wrapper 返回非零时立即给 Rust 子进程注入空 wrapper,回退到直接 rustc,避免损坏的 daemon 阻断启动;显式设置的非 sccache 自定义 wrapper 会被保留。npm run agc 的 Tauri Cargo 原先直接继承启动器环境,用户级 ~/.cargo/config.toml 的 rustc-wrapper 会在这里生效并复现同一故障(表现为 failed to run rustc to learn about target-specific information,AGC 前端与配套后端已经起来、只有 Tauri 客户端退出);现在 start-tauri-dev.mjs 在启动 Tauri CLI 前调用 scripts/dev.mjs 的 buildLocalRustProcessEnv,把两个 wrapper 变量显式写进子进程环境——空环境变量同样能覆盖 Cargo 配置文件里的 wrapper,不能只依赖「本机没配 sccache」。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、apps/ai-game-creator-shell/scripts/start-tauri-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。

Copy Artifact Production 模式下来源 Job 必须显式授权

  • 根因:Copy Artifact 的 Production 模式会把权限不足伪装成 Unable to find project for artifact copy,即使来源 Job、构建号和产物存在。SYSTEM 定时构建可能短路通过,不能证明人工触发身份有权。
  • 处理:生产者 Jenkinsfile 的 copyArtifactPermission 精确列出消费者,不用 *、全局 Job/Read 或 Migration 模式绕过。全局发号 Job 还需授权计划触发与人工发布两个消费者;修改仓库后先跑本次相关生产者,让 property 写回 Jenkins,再重跑消费者。
  • 验证:运行 npm run check:production-ops,核 live config.xml 的 CopyArtifactPermissionProperty,并按实际人工触发身份验证。
  • 关联:jenkins/Jenkinsfile.production-*-build、jenkins/Jenkinsfile.production-database-export、全局发号 Jenkinsfile、scripts/check-production-ops-guardrails.mjs。

Jenkins refspec 未开启 honorRefspec 仍会拉全部分支

  • 症状与机制:GitSCM 即使填写目标分支,未显式 refspec 并开启 CloneOption honorRefspec=true 仍可能 fetch 全部分支;而 127.0.0.1 永远指当前执行 agent,移到另一台机器就不再指向 Gitea。
  • 处理:源码准备在与 Gitea SSH 同机的 build 节点执行,首次 checkout 使用目标分支 refspec 与 shallow=true depth=1 noTags=true honorRefspec=true,必要时再逐步加深;目标部署 agent 只接收已校验脚本,不用公网 fallback 掩盖路由问题。
  • 关联:scripts/jenkins-checkout-source.sh;固定地址、凭据与 Job 拓扑见开发运维文档。

Jenkins 可选参数在 set -u 下不能裸读

  • 现象:数据库导入或导出流水线报 INCLUDE_TABLES: unbound variable,或其它可选参数在 Bash 中未定义即退出。
  • 原因:Jenkins string/boolean 参数留空时不一定会导出同名环境变量,而生产数据库导入导出脚本块启用了 set -u。
  • 处理:进入 Bash 执行块后先使用 ${VAR:-} 或 ${VAR:-默认值} 收敛成本地变量;必填项使用 ${VAR:?中文错误} 明确失败原因。
  • 验证:扫描 jenkins/Jenkinsfile.production-database-export 与 jenkins/Jenkinsfile.production-database-import,确认 INCLUDE_TABLES、CHUNK_SIZE、SERVER_BACKUP_DIRECTORY、SMOKE_HEALTH_URL 等可选参数不再裸读。
  • 关联:docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md、jenkins/Jenkinsfile.production-database-export、jenkins/Jenkinsfile.production-database-import。

Jenkins 二次 checkout 后脚本执行位会被 Git 还原

  • 现象:Genarrative-Server-Provision 已在 shell 块前面对脚本执行 chmod +x,但进入 Prepare Provision Tools 后仍报 scripts/prepare-server-provision-tools.sh: Permission denied / exit code 126。
  • 原因:该阶段会先运行 scripts/jenkins-checkout-source.sh,脚本内部执行 git reset --hard HEAD 和 git clean -fd,会把前面临时 chmod 的执行位还原为 Git 记录的 mode;若被直接执行的脚本在仓库里是 100644,二次 checkout 后仍不可执行。
  • 处理:需要直接以 scripts/*.sh 方式执行的 Jenkins 脚本应提交为 Git 100755;如果只想临时授权,必须放在 scripts/jenkins-checkout-source.sh 完成之后。
  • 验证:运行 git ls-files --stage scripts/prepare-server-provision-tools.sh,确认 mode 为 100755;重新跑 Genarrative-Server-Provision 时应进入工具下载/打包日志,而不是停在 Permission denied。
  • 关联:jenkins/Jenkinsfile.production-server-provision、scripts/prepare-server-provision-tools.sh、scripts/jenkins-checkout-source.sh、docs/【开发运维】本地开发验证与生产运维-2026-05-15.md。

Runtime bootstrap secret 原文不能进入 WASM 或发布归档

  • 现象:下载 Jenkins Stdb artifact 或检查 spacetime_module.wasm 能找到原始 bootstrap secret;Stdb Build / Publish 配了不同 credential,或 Publish 使用的 Secret File 摘要与 release manifest 不一致却仍继续发布;或者 module 已发布、/var/lib/genarrative/spacetime/runtime-service-bootstrap-secret.txt 也已更新,但模型定价首次初始化、队列 claim 或钱包调用仍报 identity 未授权。
  • 原因:把原文作为 Rust 编译环境变量会进入可下载 WASM;把 migration-bootstrap-secret.txt 归档会把构建凭据变成长生命周期 artifact。只把摘要编进 WASM、却不在 release manifest 绑定摘要并让 Publish 重算核对,仍可能把另一份 secret 配给已构建 module。另一方面,AppConfig 只在 api-server / worker 进程启动时读取直传值或 FILE,覆盖文件不会更新已运行进程;Full Build 又先发 Stdb、后发 API,不能等待后续 API deploy 才补旧 env。
  • 处理:原始 bootstrap secret 固定为 64 位十六进制。WASM 编译只接受 GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256,模块对 procedure 入参原文重新计算 SHA-256 并做常量时间比较。生产 Jenkins Build / Publish 必须使用完全相同的 Secret File credential ID:Build 只计算摘要,WASM、artifact 和 copyArtifacts 不含原文,Stdb release manifest 记录 migration_bootstrap_secret_sha256;Publish 重新读取同一 Secret File、校验 64 位十六进制并重算摘要,与 manifest 强制匹配后才把临时文件路径交给 production-stdb-publish.sh。module 发布后安装固定 runtime 文件为 root:genarrative 0440、目录 root:genarrative 0750,补齐 API / worker env,再在维护模式内重启发布前 active 的 API、controller 和 worker,并执行 API /healthz 门禁。人工构建自动生成的原文只放 gitignored server-rs/.spacetimedb/build-secrets/<version>.txt,目录 0700、文件 0600;本地 dev 的 API token 和按 server/database 作用域 secret 也分别持久化为 0600 文件。日志不得 cat 或插值打印明文。
  • 身份轮换:bootstrap secret 只允许空表首次授权,不能重复接管既有 writer。migration operator 与 runtime writer 必须互斥:operator 不能成为 writer,当前 writer 不能被授权为 operator;已有任一 operator 后,bootstrap secret 不得新增或接管 operator。生产 token 确需轮换时,使用 scripts/deploy/production-runtime-writer-identity-rotate.mjs,由当前已授权 migration operator 登录态双录新 writer identity、填写操作人和原因;procedure 必须拒绝把新 writer 设为当前 writer 或任一 migration operator,并写 editor_generation_runtime_identity_rotation 审计。成功后先核对审计,再切换 token,不得靠重启 API 隐式改 writer。
  • 验证:运行相关部署脚本 bash -n、node --check scripts/dev.mjs scripts/check-production-ops-guardrails.mjs scripts/deploy/production-runtime-writer-identity-rotate.mjs、npm run check:production-ops,扫描 artifact 清单和 diff,确认 Build / Publish credential ID 一致、manifest 摘要与 Publish Secret File 匹配,并确认不存在 migration-bootstrap-secret.txt、原文编译环境变量、cat 或生产 env 明文键,同时验证 64 位十六进制规则及手工 secret / 本地 token 文件权限。
  • 关联:server-rs/crates/spacetime-module/src/migration.rs、scripts/dev.mjs、scripts/build-production-release.sh、scripts/deploy/production-stdb-publish.sh、scripts/deploy/production-runtime-writer-identity-rotate.mjs、jenkins/Jenkinsfile.production-stdb-module-build、jenkins/Jenkinsfile.production-stdb-module-publish。

SpacetimeDB update installer 不要按带 host 后缀的下载文件名执行

  • 现象:Server-Provision 目标机阶段已经显示“使用已下载的 SpacetimeDB Linux update installer”,随后报 Error: unexpected argument '-y' found 或前置 unknown command name for spacetimedb-update multicall binary。
  • 原因:spacetimedb-update-* 不是当前离线交付的最终形态,GitHub release 页面真正可比较的缓存对象是 spacetime-x86_64-unknown-linux-gnu.tar.gz 这种 release tarball;GitHub release asset API 暴露的是 digest / SHA256,不是 MD5。
  • 处理:Windows 下载阶段应直接缓存 release tarball 和 otelcol-contrib_0.151.0_linux_amd64.tar.gz,目标机 scripts/prepare-server-provision-tools.sh 只解压本地 tarball 生成 bin/current/spacetimedb-cli 与 bin/current/spacetimedb-standalone,不要再把 update installer 当成最终离线包执行。
  • 验证:Jenkins 目标机日志不再出现 unexpected argument '-y'、unknown command name for spacetimedb-update multicall binary,后续应继续检查 bin/current/spacetimedb-cli 和 bin/current/spacetimedb-standalone 是否生成。
  • 关联:scripts/prepare-server-provision-tools.sh、jenkins/Jenkinsfile.production-server-provision。

Tauri release 的 tauri.localhost 不要交给系统浏览器

  • 现象:Windows / release 包启动桌面壳时,系统默认浏览器被打开到 http://tauri.localhost/index.html。
  • 原因:release 打包资源在 WebView 内可能表现为 tauri://localhost/index.html、https://tauri.localhost/index.html 或 http://tauri.localhost/index.html;如果导航白名单只允许 tauri: 和 https://*.localhost,http://tauri.localhost 会被误判成普通外链并交给 opener.open_url。
  • 处理:桌面壳导航策略必须把 http / https 的 *.localhost 都视为 Tauri 内部打包资源,只允许真正外部 http / https、mailto、tel 走系统浏览器。Windows release 入口还必须使用 windows_subsystem = "windows",避免正式包额外弹出控制台窗口;dev build 保留控制台日志。
  • 验证:cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml desktop_webview_navigation_stays_on_packaged_or_same_origin_pages、npm run desktop-shell:typecheck、Windows release 启动时不应打开系统浏览器或控制台窗口。
  • 关联:apps/desktop-shell/src-tauri/src/main.rs、apps/desktop-shell/src-tauri/src/shell/navigation.rs、apps/desktop-shell/scripts/check-config.mjs。

dev health patrol 不能缺少公网 HTTPS 入口配置

  • 现象:dev 上 genarrative-health-patrol.timer 正常 active,但 genarrative-health-patrol.service 最近一次运行失败;Pingora 直连彩排状态脚本只因 /etc/genarrative/health-patrol.env 缺失或 public probe 命中 http://127.0.0.1 后被 Nginx 301 而报 CRITICAL。
  • 原因:health patrol systemd unit 的 EnvironmentFile=-/etc/genarrative/health-patrol.env 允许文件缺失,脚本会退回默认 public base URL http://127.0.0.1;dev / release 的 Nginx 公开入口会把 HTTP 跳到 HTTPS,巡检按非 2xx 判失败。
  • 处理:目标机应创建 /etc/genarrative/health-patrol.env,保持 GENARRATIVE_HEALTH_PATROL_GATEWAY_MODE=nginx,把 GENARRATIVE_HEALTH_PATROL_PUBLIC_BASE_URL 指向真实 HTTPS 域名,例如 https://dev.genarrative.world;Pingora shadow 巡检同时配置 GENARRATIVE_HEALTH_PATROL_PINGORA_BASE_URL=http://127.0.0.1:18081 和与 /etc/genarrative/pingora-gateway.env 一致的 probe token。不要为了让彩排状态变绿把缺 env 降级成 warning。
  • 验证:先运行随包 node -- /opt/genarrative/current/scripts/check-production-health-patrol-env.mjs --env-file /etc/genarrative/health-patrol.env --expected-gateway-mode nginx --expected-public-base-url https://dev.genarrative.world --require-empty-public-host,再 systemctl start genarrative-health-patrol.service;最后运行 node -- /opt/genarrative/current/scripts/ops/pingora-direct-rehearsal-status.mjs --release-root /opt/genarrative/current --expect-public-gateway nginx --require-pingora-shadow --require-realpath-canary --require-current-release-gateway --fail-on-critical。
  • 关联:deploy/env/health-patrol.env.example、scripts/ops/production-health-patrol.mjs、scripts/ops/pingora-direct-rehearsal-status.mjs、docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md。

Pingora 高端口直连演练不要 source env 文件

  • 现象:在 dev 上用临时 env 启动高端口 Pingora direct 演练时,shell 报 /tmp/pingora-direct-highport-*.env: line ...: max-age=31536000,: command not found,或者临时演练进程启动后没有按预期监听 18443/18080。
  • 原因:pingora-gateway.env 是 systemd EnvironmentFile 口径,允许 GENARRATIVE_PINGORA_GATEWAY_ASSET_CACHE_CONTROL=public, max-age=31536000, immutable 这类带空格的值;它不是可安全 source 的 shell 脚本。用 shell source 会把空格后的内容拆成命令或参数。另一个容易误判的点是 Pingora 默认优雅退出窗口较长,停止临时 systemd unit 后可能短暂停在 stop-sigterm,即使监听端口已经释放。
  • 处理:高端口真实演练优先用临时 systemd unit 启动 current release 的 /opt/genarrative/current/pingora-gateway,通过 systemd-run --property=EnvironmentFile=/tmp/<run>.env --property=User=genarrative --property=WorkingDirectory=/opt/genarrative/current ... 让 systemd 解析 env;或使用显式安全 env 解析器,禁止直接 source。临时 env 要把正式 shadow 端口改到独立 loopback 端口,例如 127.0.0.1:18084,HTTPS / HTTP redirect 用 127.0.0.1:18443 / 127.0.0.1:18080,access log 写独立文件。演练结束先 systemctl stop <临时unit>,再用 ss -ltnp 确认高端口已释放;若临时 unit 仍停在 deactivating/stop-sigterm 且只剩演练进程,可对该临时 unit 执行 systemctl kill -s SIGKILL <临时unit> 收尾,不要碰正式 genarrative-pingora-gateway.service。
  • 处理补充:正式 plan:pingora-direct-cutover / check-pingora-release-readiness.mjs --dry-run-cutover --require-direct 生成的 runbook 默认读取 active /etc/genarrative/pingora-gateway.env,不会自动使用 /tmp 候选 env。若只生成了候选 direct env,必须先在维护窗口内把候选 env 提升为 active env,并确认 Nginx 已释放 80/443,再执行 runbook 的 direct preflight、enable dry-run 和 enable apply;否则 runbook 第 5 步仍会按 shadow env 报缺 TLS_LISTEN、HTTP_REDIRECT_LISTEN、cert/key、FORWARDED_PROTO=https 以及 direct-entry capability。不要把候选 env 的 loopback / 高端口预检通过误解为 active env 已满足正式直连门禁。
  • 验证:先跑 check-pingora-direct-preflight.mjs --env-file <临时env> --require-live-env --check-cert-readable --check-service-user-cert-readable --check-ports-free --allow-loopback-only;启动临时 unit 后跑 check-pingora-direct-live.mjs --https-base-url https://127.0.0.1:18443 --http-base-url http://127.0.0.1:18080 --host <域名> --redirect-host <域名> --redirect-base-url https://<域名> --require-wss-upgrade --pingora-access-log <临时log> --insecure-tls --json,要求 OK 且 direct-access-log matchedCount == checked。收尾后复核 80/443 仍由 Nginx 监听,正式 Pingora shadow 仍为 127.0.0.1:18081。
  • 关联:scripts/check-pingora-direct-preflight.mjs、scripts/check-pingora-direct-live.mjs、deploy/pingora/pingora-gateway.env.example、docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md。

Pingora 直连接管同 IP 多域名前先确认 Host 和证书覆盖

  • 现象:dev 上准备让 Pingora 直接绑定 0.0.0.0:80/443 时,只按 dev.genarrative.world 配置证书和路由会让同 IP 的 git.genarrative.world 也进入主站 Pingora 路由,Gitea 可能不可访问;即使补了 Gitea Host 路由,如果仍使用只覆盖 dev.genarrative.world 的单域名证书,浏览器和 Git 客户端访问 git.genarrative.world 也会遇到证书域名不匹配。
  • 原因:Nginx 原来通过多个 server_name vhost 承载主站和 Gitea;当前 Pingora direct listener 默认只有一组 TLS cert/key,且路径路由本身无法区分同一 IP 上的多个域名。
  • 处理:direct env 必须配置 GENARRATIVE_PINGORA_GATEWAY_GITEA_HOSTS=git.genarrative.world 与 GENARRATIVE_PINGORA_GATEWAY_GITEA_UPSTREAM=127.0.0.1:3000;TLS 证书必须同时覆盖 dev.genarrative.world 和 git.genarrative.world,并通过 pingora-tls-cert-sync.mjs 同步到 Pingora 私有目录后再指向 env。不要通过“临时释放 Gitea vhost”把 Gitea 从切换窗口里牺牲掉。
  • 验证:本地 npm run check:pingora-gateway-smoke 必须覆盖 Gitea Host 整站转发、维护模式不拦截 Gitea Host 和 access log proxy_target=Gitea;dev 切换后除 https://dev.genarrative.world/ 外,还必须验证 https://git.genarrative.world/ 返回 Gitea,HTTP 到 HTTPS redirect 保留正确 Host,Pingora access log 中有 host=git.genarrative.world / proxy_target=Gitea。
  • 关联:server-rs/crates/pingora-gateway/src/main.rs、deploy/pingora/pingora-gateway.env.example、docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md。

Jenkins Job UI 参数会被 SCM Jenkinsfile 覆盖

  • 现象:在 Jenkins Job 页面给 MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID 配了默认值,下一次加载 Declarative Pipeline 后又变空或恢复旧描述;04:00 Full Job 还可能因默认选择 pause-after-stdb 且 approvers 为空而失败。
  • 原因:这些 Job 使用 Pipeline script from SCM,parameters {} 和 triggers {} 会作为 Job property 回写现场配置;只改 UI 不是持久修复。构建编排如果不显式关闭下游 PUBLISH_AFTER_BUILD,还会受下游默认值漂移影响。
  • 处理:credential ID 和参数默认值写回三个 Jenkinsfile;仅供开发使用的 dev 定时 Full Job 默认 STDB_API_ROLLOUT_MODE=normal,三路 Build 调用显式传 PUBLISH_AFTER_BUILD=false,再由 Full Job 统一按 Stdb → API → Web 发布。Secret 原文只放 Jenkins Secret File,旧 Secret Text 保留给 Import / Export。
  • 验证:推送后让 Full / Stdb Build 用不存在的源码分支在 checkout 阶段 fail-closed,让 Stdb Publish 用空构建版本在 Prepare 阶段 fail-closed,以安全刷新参数 schema;随后只读检查三个 live config.xml 的参数描述和默认值,确认 rollout 默认值为 normal、Full 与 AGC Job 都不再带 cron(定时只来自 Genarrative-Scheduled-Revision-Trigger),并确认刷新运行未进入 publish / deploy stage。
  • 关联:jenkins/Jenkinsfile.production-full-build-and-deploy、jenkins/Jenkinsfile.production-stdb-module-build、jenkins/Jenkinsfile.production-stdb-module-publish、scripts/check-production-ops-guardrails.mjs。

Full 结束后保持维护不能只加一个 UI 参数

  • 现象:Full Job 参数页没有“完整发布成功后是否退出维护”选项,或者补了选项后 API readiness 一通过仍自动撤掉维护。
  • 原因:维护退出发生在随 API artifact 发布的 production-api-deploy.sh 内;Full、API Deploy Job 和脚本任一层没有透传,最终都会回到固定执行 maintenance-off.sh。Declarative Pipeline 参数还要等 live Job 加载新版 Jenkinsfile 后才会刷新。
  • 处理:Full 使用 EXIT_MAINTENANCE_MODE_AFTER_COMPLETION 表达产品选择,Stdb Publish 和 API Deploy 全程固定保持维护,Web Deploy 成功后才进入独立最终退出阶段;API Deploy 的独立 KEEP_MAINTENANCE_MODE 再转换为脚本 --keep-maintenance-mode。API deploy 还必须把 production-api-deploy.sh、maintenance-on.sh 和 maintenance-off.sh 从同一 build artifact 复制进 current release,否则 Full 最终阶段即使有选项也找不到随包退出脚本。默认值仍在 Full 结束时退出维护,避免定时 dev 发布行为变化。
  • 验证:API deploy fixture 必须覆盖成功发布并保留 marker,还要断言 current release 中三个部署 / 维护脚本存在;生产运维静态门禁同时反查 Full 参数、下游透传、API Deploy 参数和脚本 flag。推送后用 fail-closed 首阶段运行刷新 live Job 参数,再核对 config.xml,不能只看仓库文件。

临时维护公告不能提交进版本化默认页

  • 现象:现场已恢复通用维护页,但后续 Web Deploy 或下一次进入维护后,又显示昨天的“今天晚上 HH:MM~HH:MM”公告。
  • 原因:public/maintenance.html 会被 Vite 复制进 web.tar.gz,Web Deploy 解包后把 /srv/genarrative/web 指向新制品;临时公告一旦进入该源码,就会成为每次发布都恢复的长期内容。旧维护 on / off 只控制 marker,浏览器缓存不是根因。
  • 处理:版本化默认页只保留无日期通用文案;临时公告用 maintenance-on.sh --page-file <公告HTML> 安装到 /var/lib/genarrative/maintenance/page.html。Nginx / Pingora 优先读取运行态公告,退出维护时同步清理;不要再原地编辑 /srv/genarrative/web/maintenance.html 或提交临时公告到 public/。
  • 验证:npm run check:maintenance-page 必须拒绝相对日期、具体日期和具体时间,并覆盖公告安装、同窗口保留、退出清理与新窗口清残留;Pingora smoke 必须证明运行态公告优先且删除后回退默认页。
  • 关联:public/maintenance.html、scripts/deploy/maintenance-on.sh、scripts/deploy/maintenance-off.sh、deploy/nginx/snippets/genarrative-maintenance.conf、server-rs/crates/pingora-gateway/src/main.rs。

Gitea PR runner 不能把宿主 Docker socket 当普通 volume

  • 现象:act_runner 的 container.docker_host 留空时,job inspect 会出现 /var/run/docker.sock:/var/run/docker.sock;PR 脚本即使 valid_volumes: [],仍可直接调用 Docker API读取其它容器、挂宿主目录或取得 runner 配置。另一类迁移故障是 rootless daemon可以启动,但 bwrap 在 job 内报 No permissions to create new namespace、Failed to make / slave 或 Mount too revealing。
  • 原因:空 docker_host 会自动发现并把控制 socket 传播到 job;rootful Docker 的 seccomp、AppArmor 与 system-path masks 又不支持完整 nested bwrap。Runner 2.0.0 还有一处独立合并缺陷:parseSystemPaths 把 systempaths=unconfined 转成显式空 slice 后,mergo.WithOverride 不覆盖 empty value,真实 job 又恢复 Docker 默认 masks,表现为配置文件写了 unconfined、手工 docker run canary 也成功,但 Actions job 仍在 --proc /proc 返回 EPERM。直接使用 --privileged、外层 CAP_SYS_ADMIN 或 host executor 会把测试跑绿建立在破坏 PR 隔离的前提上。宿主启用 Clash fake-IP 时,简单按 DNS 的 198.18.0.0/15 判断公网还会误拒所有公共依赖。
  • 处理:先把 Gitea 升到至少 1.26.4,再用固定 digest 的 Runner 2.0.0 rootless DinD;外层保持非 privileged、无 CAP_SYS_ADMIN,内部 Docker 仅 Unix socket,runner 固定 docker_host: "-"。若版本仍有上述 empty-slice 缺陷,只做 merge 后保留 MaskedPaths=[] / ReadonlyPaths=[] 的最小补丁并固定自有镜像 digest,不对整个 HostConfig 启用 overwrite-empty。job 使用独立 internal network,Gitea 经 reverse gateway,公共 80/443 经使用公共 DoH 验证真实 IP、拒绝私网/保留地址/metadata 的 proxy;DoH 要缓存并合并同域并发,长下载 timeout 不能只有 60 秒。rootless job 内的 bwrap namespace/proc 选项不能复制到宿主 rootful runner。apt 步骤在 root 时直接执行,非 root 时用 sudo -E,否则 sudo env_reset 会让 apt 丢失 proxy。
  • 运维陷阱:从容器内运行 Compose 时,宿主 /opt/gitea-stack 必须挂到容器同名绝对路径;挂成 /stack 会让相对 bind source 被 daemon解析为宿主 /stack/...,表现为 Gitea进入空安装页、gateway 脚本“缺失”。发现后不要迁移空库,立即用同路径 mount 重建并核对原数据大小、installed 日志、仓库数和 API。切换前保留冷数据 tar、pg_dumpall 和原 compose/env/runner 配置,备份与 token 不提交 Git。
  • 验证:同时检查 Gitea 版本、runner declare、外层 Privileged=false/无 CapAdd/无宿主 socket、inner job Binds=[]、MaskedPaths=[]、ReadonlyPaths=[]、固定 image digest、/var/run/docker.sock 不存在、公共 proxy 可用、直连公网/Postgres/metadata 失败,以及完整 bwrap canary。AI 原生壳的共享 Agent Runtime 后台锁 suite 固定单线程执行;并行全量出现锁或异步终态失败、逐项单线程全部通过时,修正 suite 调度口径,不放宽断言。最后重跑四个 CI job;checkout 成功但 apt/rustup/npm 同时失败时,先排 proxy/env,而不是改测试。
  • 关联:.gitea/workflows/project-ci.yml、docs/【开发运维】本地开发验证与生产运维-2026-05-15.md、docs/project-memory/shared-memory/development-workflow.md。

固定 digest 不等于每个 CI job 都要强制拉镜像

  • 现象:四个 Gitea Actions job 在约 20 秒内同时失败,checkout 和测试都没开始;setup 日志显示 Docker 对已固定的 docker.gitea.com/runner-images@sha256:... 发起 manifest HEAD,随后以 net/http: TLS handshake timeout 结束。
  • 原因:镜像 label 固定 digest 只防止内容漂移;container.force_pull: true 仍会让每个 job 调用 Docker image create/pull 并依赖 registry 即时可用,即使 rootless Docker 本地已有该精确 RepoDigest。并发四个 job 还会同时放大同一外网 TLS 故障。
  • 处理:继续使用完整 digest,把 Runner 设为 force_pull: false;首次部署或变更 digest 时,在切换 label 前对精确 digest 执行有界重试拉取,并用内层 docker image inspect 核对 RepoDigest。保留上一份 runner 配置和已验证镜像备份,切换失败时回滚配置,不改用浮动 tag。
  • 验证:重启 runner 后先等待内层 docker info 就绪,再 inspect 精确 digest 并确认 runner declare;重跑真实 PR 的四个 job,必须越过原先的启动失败窗口。运行中 job 仍要复核 Privileged=false、Binds=[]、MaskedPaths=[]、ReadonlyPaths=[] 和独立网络,防止稳定性修正意外放宽隔离。
  • 关联:docs/【开发运维】本地开发验证与生产运维-2026-05-15.md、docs/project-memory/shared-memory/development-workflow.md。

CI 下载缓存存在不等于当前 lock 已离线闭合

  • 症状与机制:运行时 *_cache_lock=partial 表示镜像缓存与当前 checkout 不同,不能证明新增依赖已缓存。cargo metadata --no-deps 也不读取依赖 archive,不能替代真实离线 fetch。
  • 处理:可信分支落地后刷新镜像,对各当前 lock 执行真实 cargo fetch --locked --offline。PR job 仍各自根 npm ci,不烘入 node_modules/target,不挂跨 PR 可写缓存。
  • 网络边界:CARGO_NET_RETRY 未覆盖全部 index/config.json TLS 握手失败;下载阶段增加有界整命令重试,最终仍执行锁定的离线闭合验证,不用无锁重试或跳过验证。
  • 关联:docs/【开发运维】本地开发验证与生产运维-2026-05-15.md、development-workflow.md 的 Gitea CI 依赖闭合。

Gitea Actions HTTPS CONNECT 隧道必须双向收束 socket(2026-08-07)

  • 现象:CI 的 npm ci 高频出现 ECONNRESET / network aborted,Cargo 则出现 crates.io TLS EOF、连接超时或下载失败;同一出口 gateway 容器看似健康,却累计自动重启数百次,日志反复出现 Socket.ondata -> Writable.write -> write EPIPE -> Unhandled 'error' event。
  • 原因:HTTPS CONNECT 建立后使用 upstreamSocket.pipe(clientSocket) 与反向 pipe,但只监听 upstream error;客户端在 DNS 等待、下载或 job 清理期间关闭连接时,pipe 继续向已断开的 client socket 写入,未处理的 EPIPE 会让 Node 进程退出。unless-stopped 自动拉起和浅层 healthcheck 会掩盖崩溃,所有并发 npm / Cargo 隧道同时被 reset。
  • 处理:CONNECT 一开始就为 client socket 注册 error / close,解析完成后为 upstream socket注册同样的双向销毁处理;DNS 返回、写 200 和开始 pipe 前都检查 client 是否已销毁。任一端 error、close 或 timeout 都幂等 destroy 两端,不把普通客户端 reset 写成错误日志。不要用进程级 uncaughtException 吞掉问题,也不要只增加 npm/Cargo 重试掩盖 gateway 崩溃。
  • 验证:在独立 canary 和正式 gateway 上分别并发制造至少 500 次“CONNECT 后立即断开”,随后确认容器仍运行、restart count 不增加、日志无 EPIPE;再通过同一 proxy 对 npm registry 与 crates index 建立完整 TLS 隧道。切换前仍须确认 Gitea 无活跃 run 且 Runner 内层无 job 容器。

统一 npm workspace lock 不能丢失可选 WASM 包的 bundled 依赖节点(2026-08-21)

  • 现象:根 npm ci 在安装前失败,报告统一 lock 缺少 @emnapi/core / @emnapi/runtime;错误版本可能是 registry 当前满足 ^1.11.1 的最新版,而不是原 lock 中曾记录的版本。
  • 原因:重写或解决根 workspace lock 冲突时,保留了 AGC 使用的 @tailwindcss/oxide-wasm32-wasi 对 bundled @emnapi 包的声明,却删掉了对应嵌套 package 节点。npm 会重新解析当前 registry 版本并判定 manifest 与 lock 不同步;这不是单一 npm 版本问题,也不表示应用应直接依赖两个 @emnapi 包。
  • 处理:只在最新目标分支的仓库根执行固定 npm 的 npm install --package-lock-only --ignore-scripts,保留 npm 对全部 workspaces、bundled 节点及 peer / optional 标记的完整规范化结果;确认各 workspace manifest 没有意外变化,不要手工只补报错中的两个版本。
  • 验证:至少用 Jenkins 对应固定 npm 和当前开发环境分别执行干净的根 npm ci,核对 bundled 节点后再运行 npm run check:npm-workspaces、AGC typecheck、编码检查和 git diff --check;禁止恢复独立 AGC lock 或子目录 npm ci。

AGC Skill 指纹与安装内容必须跨平台一致(2026-08-21)

  • 现象:内置 Skill 文件集合没有缺失,原生测试却统一报内容指纹不匹配;安装后的 Skill 文件与 manifest 摘要不一致会导致构建校验失败。
  • 原因:审核文件定稿后未按最终字节重新生成 manifest SHA-256;安装和原生 Skill loader 必须看到与清单一致的 UTF-8 文件集合。
  • 处理:Skill 文件变化与 manifest 指纹更新必须同次提交,并提升审核包版本;references 由 Codex 原生按 Skill 声明的相对路径读取,不再经过 AGC 自定义资源读取器。
  • 回归补充:即使 Skill 文件本轮没有变化,也不能从旧提交或旧构建结果复制清单指纹;必须对当前工作树按 UTF-8 读取、将 CRLF 规范为 LF 后现场重算,并在提交前运行原生 Skill Pack 校验。Git 的 eol=lf 不能阻止编辑器在干净工作树里留下少量混合 CRLF,而 Cargo include_bytes! 会读取这些原始字节;因此运行时计算与安装也必须使用同一规范化函数。运行时只报告排序后的首个不匹配项,不能据此假定其余 Skill 已通过。
  • 验证:逐项按排序后的 relativePath + NUL + canonical UTF-8 LF bytes + NUL 重算并核对 manifest;Rust 单测覆盖 LF / CRLF 指纹等价和安装结果只含 LF,并确认隔离 HOME 中的根 Skill 与 references 可由 Codex 原生读取。

Gitea CI 预构建镜像不能只靠 tag 判断内容

  • 现象:宿主已重建带日期修订 tag 的 genarrative/gitea-project-ci 镜像,但 genarrative-ci job 仍跑旧内容,或直接报 image not found;另一种危险操作是只改 runner label,没把对应镜像装入 rootless runner 的内层 Docker。
  • 原因:宿主 Docker 和 runner 内层 Docker 是两个镜像库,同名 tag 可指向不同 Image ID。基础镜像 digest 和 Node tarball 哈希能锁定关键输入,但重建后仍必须把最终完整 Image ID 当作 runner 映射的事实源,不能从 tag 名推断二进制内容。
  • 处理:使用 scripts/gitea-ci-job-image.sh build/verify,用 export 在仓库外保存镜像归档与便携 SHA-256 sidecar,再用 load-runner 将镜像导入内层、比对两侧 Image ID 并执行 bwrap / Chrome canary。确认无活跃 job 后,把当前 config 备份到仓库外受控位置,再将 genarrative-ci 映射到新的 docker://sha256:... 并执行 docker restart --timeout 660 gitea-runner。保持内层 Docker 持久化和 force_pull: false;精确 ID 缺失时失败关闭,不回退浮动 tag。
  • 验证与回滚:重启后先跑真实 PR 的四个 job,再清理旧镜像。失败时先把 workflow runs-on 改回 ubuntu-latest,再恢复 runner config 备份并重启;不在 Git、共享文档或日志中记录 config 备份路径、注册信息或 token。
  • 重启边界:docker restart --timeout 660 只设置容器停止宽限,不能替代 Runner drain。rootless DinD supervisor 可能与 runner 同时停止内层 dockerd,使仍在收尾的 job 因连接关闭被标记失败;切换前必须同时确认 Gitea 没有 in_progress run 且内层 docker ps 为空。误触发时只重跑受影响的失败 job,不重跑已成功项。
  • 关联:deploy/container/README.md、docs/【开发运维】本地开发验证与生产运维-2026-05-15.md、docs/project-memory/shared-memory/development-workflow.md。

生产 API 发布重装 worker unit 不能丢失自定义路径(2026-07-23)

  • 现象:Server-Provision 已按自定义 current link 和 env 路径安装 worker systemd unit,但下一次 API 发布后,worker 可能重新读取 /opt/genarrative/current 与 /etc/genarrative/*.env;默认路径仍有旧 release 时,服务 active 和部署成功都不能证明新二进制已运行。
  • 原因:发布包中的 BgFilter、external-generation worker 和 controller unit 是带默认路径的模板;deploy 若直接 install 原文件,会覆盖 provision 已渲染的目标机 unit。external-generation 专属 env 还是可选加载,错误路径可能不会阻止服务进入 active。
  • 处理:API deploy 安装三个 unit 前必须按本次 current、API env 和各角色 env 参数渲染临时文件,安装后保留 release 内原始模板不变;controller 自定义 env 由 --controller-env-file 显式传入。API Deploy 与 Full Job 必须同步暴露并透传 controller/BgFilter env,不能让流水线回退默认路径。自定义服务名表示沿用目标机自管 unit,不进入默认 unit 安装分支。
  • 验证:部署 guard 使用临时自定义绝对路径,直接读取实际安装目录中的三个 unit,核对 WorkingDirectory、ExecStart、共享 API env 与角色 env,不能只用 fake systemctl is-active 判绿。
  • 关联:scripts/deploy/production-api-deploy.sh、scripts/check-production-api-deploy.mjs、scripts/jenkins-server-provision.sh、docs/【开发运维】本地开发验证与生产运维-2026-05-15.md。

Git 忽略的 AGC dist 会让 Windows 继续运行旧 Linux 路径交互(2026-08-14)

  • 现象:源码已经移除项目页常驻路径输入框,Windows 客户端却仍显示 /tmp/genarrative-ai-game-draft,新“打开项目 / 新建项目”交互也没有出现。
  • 原因:apps/ai-game-creator-shell/dist/ 是 Git 忽略的本地构建产物,可能跨提交保留旧 JS;复用旧 dist、旧 EXE 或旧安装包时,Tauri 会继续嵌入旧前端。测试模式曾把 /tmp 同时当作产品初值,也让旧构建和测试夹具的边界难以辨认。
  • 验证:运行 frontend dist guard 定向测试、AGC AppSurface 的双主按钮 / picker 防重复 / 首页回车自动创建回归、Tauri release --no-bundle smoke,并确认新 dist 不含旧路径;Windows 实机项目组不得出现常驻路径框或 /tmp,原生 picker 从系统默认位置打开。视觉验收检查 1280×720 最小横屏与 1280×800 默认窗口的紧凑项目表格和原生 picker。
  • 关联:apps/ai-game-creator-shell/src/app/constants.ts、apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts、apps/ai-game-creator-shell/src-tauri/src/commands.rs、apps/ai-game-creator-shell/src-tauri/build.rs、apps/ai-game-creator-shell/src-tauri/tauri.conf.json。

api-server 嵌入仓库外资源时必须同步容器构建上下文(2026-07-31)

  • 现象:本地 cargo test 可以编译 MCP 与 Skill 下载模块,但 api-server 镜像在 Rust 编译阶段报 include_str! 找不到 OpenAPI 或 Skill 文件。
  • 原因:本地工作树包含完整仓库,而容器 Rust builder 原先只复制 server-rs/ 和 public/;crate 中向上引用的 docs/openapi/、.codex/skills/ 不会自动进入镜像构建文件系统。
  • 处理:凡 api-server 通过 include_str! 使用仓库根目录资源,都要在 deploy/container/api-server.Dockerfile 的 builder 阶段显式复制对应权威目录;不要再复制一份内容到 crate 内形成平行事实源。
  • 验证:除本地 Cargo 测试外,检查 Dockerfile 构建上下文覆盖所有 include_str! 相对路径;新增或移动嵌入资源时同步更新容器 COPY 和接入文档。
  • 关联:deploy/container/api-server.Dockerfile、server-rs/crates/api-server/src/external_mcp.rs、server-rs/crates/api-server/src/external_skill_api.rs、docs/openapi/genarrative-external-v1.openapi.json。

Mach-O 文件头校验必须覆盖反字节序魔数(2026-08-05)

  • 现象:macOS arm64 的 Tauri release 已成功构建且 file 明确认定为 Mach-O,产物 staging 仍报“must be an executable Mach-O file”。
  • 原因:脚本用 Buffer.readUInt32BE(0) 读取文件头,却只比较 0xfeedfacf 等正序数值;arm64 常见头字节是 cf fa ed fe,读取结果为 0xcffaedfe。
  • 处理:文件头白名单同时覆盖 32/64 位与 fat Mach-O 的正序和反字节序合法魔数,并由桌面配置门禁同时反查 staging 脚本和根级产物检查,不能改成只按扩展名或构建退出码判断。
  • 验证:在 macOS 上构建真实 desktop-shell release,运行 npm run desktop-shell:stage-release-binary,再由 npm run check:native-shells 校验 staged 产物。

BuildKit secret 不等于镜像内 secrets 不可提取(2026-08-22)

  • 现象:构建时使用 BuildKit secret mount,日志和普通 build context 都没有出现明文,于是误以为最终镜像也能不可提取地保存 secrets,随后将镜像 push 或导出给不同信任域。
  • 原因:BuildKit secret mount 只避免秘密作为 ARG / COPY 进入构建上下文和中间指令;一旦 Dockerfile 把 mount 的内容安装到最终 rootfs,任何能读取、保存或运行该镜像的主体都可以提取它。
  • 处理:预览固定 .env.local 与 secrets 只从 Jenkins 宿主受控路径读取,严格校验目录 0700、文件 0600、owner、普通文件与非链接边界;只将它们安装到 api-runtime:/srv/genarrative/.env.local 与 /srv/genarrative/.env.secrets.local 并设为 0400,明确排除 Nginx、Web、artifact 和其它镜像。镜像禁止推送或导出到跨信任边界。
  • 更新与验证:任一源文件变更不会改动已存镜像,必须重建并替换 API 与 worker 容器;不能用重启代替。验收同时扫描 transcript/context/artifact 零泄漏,检查只有 API 与 worker 最终 rootfs 存在目标文件,并验证容器显式运行 env 优先覆盖内置值。

SpacetimeDB ping 健康不代表完整模块能在内存上限内实例化(2026-08-22)

  • 现象:空库 /v1/ping 已成功且容器显示 healthy,但 spacetime publish 在 Publishing module... 后连接提前关闭,紧接着端口拒绝连接。
  • 原因:当前完整模块 init 的 RSS 会超过基础 Compose 旧 896m cgroup 上限;内核 OOM kill SpacetimeDB,客户端只看到传输错误,容易被误判为网络竞态。
  • 处理:先查 kernel journal 的 Memory cgroup out of memory 和目标容器 ID,再把本地/预发完整容器 SpacetimeDB 上限统一为 2g;保留 page pool 限制。不要只增加 publish 重试,也不要把 /healthz 或首页改成数据库就绪探针。
  • 验证:用新空卷完成模块 publish、五服务启动和 Web/API smoke,并确认容器未 OOM、SpacetimeDB 与 API/Nginx 最终 healthy。

Tauri NSIS 工具链须在编译前预置、验证并保留缓存

  • 缓存身份:Tauri bundler 使用自己的 NSIS,不使用 PATH 中的 makensis.exe。Windows 配置保持 bundle.useLocalToolsDir: true,实际工具位于 src-tauri/target/.tauri/NSIS;Jenkins LocalSystem 的 %LOCALAPPDATA%/tauri 可能不可执行,不能通过关闭此配置规避。Unable to start child process, error 0x2 应核查真实运行用户、缓存目录写权限、ACL/EDR 与该绝对路径的 makensis.exe -VERSION。
  • 下载与清理:bundler 现场下载无重试,响应截断会在 Rust release 成功后报 io: unexpected end of file。scripts/nsis-toolset.mjs / ensure-nsis-toolset.mjs 在 Windows 打包前按固定 SHA1 预置,下载做有界重试;工作区外原始归档缓存默认 %ProgramData%/genarrative/tauri-nsis-cache,可由 AGC_TAURI_NSIS_CACHE_DIR 覆盖,镜像沿用 TAURI_BUNDLER_TOOLS_GITHUB_MIRROR_TEMPLATE / TAURI_BUNDLER_TOOLS_GITHUB_MIRROR。
  • 执行顺序:buildRelease 仅对需打包的 Windows 目标准备工具;Jenkins 的预置与 -VERSION 预检应在 Rust 编译前失败关闭。Checkout 的 git clean -fdx 必须排除 apps/ai-game-creator-shell/src-tauri/target/.tauri,否则每轮都会重新下载。升级 @tauri-apps/cli 时同步核对归档 URL、SHA1 与必需文件清单。
  • 验证:node --test apps/ai-game-creator-shell/scripts/nsis-toolset.test.mjs apps/ai-game-creator-shell/scripts/build-release.test.mjs 覆盖已就绪零下载、离线缓存还原、失败重试、哈希不符与归档越界拒绝;真实 Windows 节点核对工具可执行与阶段顺序。测试路径使用 fileURLToPath / defaultAppRoot(),模拟 Windows/POSIX 时分别用 path.win32 / path.posix,避免 /C:/... 或宿主分隔符造成假失败。

2026-09-15 Jenkins Stdb 发布临时目录必须允许服务用户遍历

  • 现象:Genarrative-Stdb-Module-Publish 在备份和 SpacetimeDB 就绪后,于 spacetime publish 报 Permission denied。
  • 原因:Jenkins 以 root 运行时 ${HOME}/data/tmp 位于 /root 下;即使发布临时子目录已 chown 给 spacetimedb,父目录仍不可遍历。
  • 处理:发布给 --run-as-user 的 WASM 临时目录改用 /var/tmp,继续使用随机目录并在退出时清理。

2026-09-22 本地 node_modules 里的内置 Codex 原生包过旧会让 AGC build script panic

  • 现象:npm run agc 编到壳 crate 的 build script 时中止,stderr 是 panicked at build.rs:103: Codex 原生包版本、布局或架构不匹配目标 x86_64-pc-windows-msvc,stdout 只有 cargo:rustc-env=AGC_BUILD_TARGET=...。
  • 原因:build_support/codex_bundle.rs 把随包 Codex CLI 钉在一个固定版本,而本地 node_modules/@openai/codex-win32-x64/vendor/<target>/codex-package.json 还停在上一次安装的旧版本。package-lock.json 早就升到新版本,缺的只是本地安装;这条判据只看版本元数据,文件齐全、架构正确也照样拦。
  • 处理(现行口径):在仓库根目录执行 npm ci(不要改成子目录或单包安装),随后 node_modules/@openai/codex/package.json 与 vendor 的 codex-package.json 版本应当一致。Windows 上 npm ci 会先 unlink 整个 node_modules:RustRover 的 Tailwind language server / oxide-helper 进程会占住 @tailwindcss/oxide-*.node,报 EPERM: operation not permitted, unlink ... 时先结束这些 helper 再重试,否则会停在半装状态。
  • 关联:apps/ai-game-creator-shell/src-tauri/build.rs、apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs、package-lock.json。

2026-09-22 Rust 对象快照必须对齐 CI 编译目录和 Cargo 环境

  • 根因:Rust 对象缓存键包含编译 cwd 与 Cargo 环境,随机 wrapper 路径、profile/features 或预热环境不同都会导致热缓存 miss;仅配置 SCCACHE_BASEDIRS 不足以消除 cwd 差异。
  • 处理与判据:预热与 CI 对齐 checkout 路径、Cargo 工作目录、固定 wrapper 路径和环境;daemon/socket 使用 job 私有目录,保持到 report 显式停止以免统计重置。相同源码与资源上限、独立干净 target 对比无缓存/冷/热编译,真实热命中有净收益才切换镜像。不得恢复共享可写 target,或把测试缓存扩成发布缓存;miss 后 READ_ONLY 仍有打包开销,不能承诺无成本。
  • 回收边界:容器内删对象不释放底层镜像;维护只回收预先登记的自有 Image ID/tag 与归档,保留当前、回滚版、基础镜像及容器引用,不能只按 tag 判断。Gitea 未 finalized 的上传块可能不在仓库 REST 中,只清目标仓库已结束且过期 run 的专属标识普通文件;未知文件、符号链接与历史试验版本留待确认,不改数据库或全局 prune。
  • 切换边界:Runner 超时、disabled 或暂时无容器都不能证明服务端未领取任务。FetchTask 转发前持久化任务 ID,最终日志与清理后的最终 UpdateTask 才清账;暂停新领取、在途/账本为零且活动容器为空才能切换。未知协议/响应、崩溃遗留标记或缺 active_tasks 必须停止切换;首次接入/账本升级选空闲窗口,核对真实 FetchTask 来源,不能用 .runner mtime 证明地址已加载。

渠道窗口配置不得用部分对象替换 Tauri 窗口数组

  • 根因:Tauri --config 使用 JSON Merge Patch,对象递归合并但数组整体替换。渠道只写 app.windows: [{ title }] 会覆盖完整窗口数组,使 label、尺寸、decorations 回落默认值,并失去按窗口 label 绑定的 capability。
  • 处理:createChannelConfig() 从基线读完整 client 窗口对象,只覆盖 title;同步保留 label、尺寸、最小尺寸与 decorations=false。capability 仍按 label 匹配,测试须按同一 merge patch 语义核对完整窗口合同。
  • 当前网络边界:登录与素材直传均已由 Rust facade 承担,渲染层不再授予 http:default;旧“登录 HTTP ACL 拒绝”症状不作为现役诊断依据。窗口 label 漂移仍会影响原生对话框、剪贴板、opener/updater 等权限。
  • 关联:apps/ai-game-creator-shell/scripts/build-release.mjs、build-release.test.mjs、check-config.mjs、src-tauri/tauri.conf.json、src-tauri/capabilities/main.json。

自绘标题栏双击最大化走的是另一条命令,只授 toggle-maximize 会以未捕获拒绝冒到错误池

  • 现象:双击 AGC 自绘标题栏的拖拽区,窗口不最大化,错误池多一条 unhandledrejection:window.internal_toggle_maximize not allowed. Permissions associated with this command: core:window:allow-internal-toggle-maximize, core:window:default;同一根标题栏上的最小化 / 最大化按钮却都正常。
  • 原因:同一个用户动作对应两个不同命令。按钮走 JS API getCurrentWindow().toggleMaximize() → plugin:window|toggle_maximize(权限 core:window:allow-toggle-maximize);拖拽区双击由 Tauri 注入的 drag.js 派发 → plugin:window|internal_toggle_maximize(权限 core:window:allow-internal-toggle-maximize)。capability 只授了前者,ACL 拒绝后 drag.js 里那个没人接管的 invoke 直接变成全局 unhandledrejection,于是症状看起来像「窗口按钮正常、双击没反应还多一条错误上报」。
  • 处理(现行口径):带 data-tauri-drag-region 且要保留「双击最大化」的窗口,capability 必须显式列出 core:window:allow-internal-toggle-maximize。core:window:default 虽然包含这条,但 capability 不引用 default 集合时不会生效;新增窗口或新 capability 时按同一口径核对全部窗口手势。
  • 验证判据:改 capability 后必须重新编译并重启客户端(编译期生成 ACL,运行中的窗口不会继承),再双击标题栏:窗口应最大化,且错误池不再新增该条。
  • 关联:apps/ai-game-creator-shell/src-tauri/capabilities/window-chrome.json、apps/ai-game-creator-shell/src/components/WindowChrome.tsx。

按目录扫描更新产物时须核对包内版本与渠道身份

  • 风险:复用构建工作区里残留的其它版本或渠道更新包,会被扫描式清单生成器选中;对象存在且签名有效仍可能发错包。
  • 现行口径:macOS 构建前清理同类 *.app.tar.gz、签名与 DMG 产物,构建后检查 Info.plist 的版本、identifier、产品名,并断言清单引用的是本轮包。核对已发布清单时设置 AGC_UPDATE_VERIFY_DOWNLOAD=1 后运行 npm run check:agc-update-channel-manifests:检查 macOS Info.plist、Windows PE 版本资源与清单一致,以及渠道间包体隔离。文件名的版本段也须与本轮版本一致。
  • 排查:遇到「清单签名正常但客户端更新到错误版本」时,先打开包核对身份,再查扫描目录的残留;不要只看 URL 和签名。
  • 上线边界:构建侧修复不代表已发布对象已修正,线上包仍须按上述清单核验重新确认。
  • 关联:apps/ai-game-creator-shell/scripts/build-macos-ci.mjs、apps/ai-game-creator-shell/scripts/macos-release-identity.mjs、apps/ai-game-creator-shell/scripts/build-release.mjs。

Jenkins 取证路径与 inbound launcher 会影响离线节点恢复

  • 症状与机制:AGC 子 Job 停在 Still waiting to schedule task / // node 前时,先查节点状态。本实例 API 位于 /jenkins 上下文,直接访问根路径可能返回 302/403,不能据此断定节点 API 不可用。
  • 处理:读取 /jenkins/computer/api/json?tree=computer[displayName,offline,offlineCauseReason];OfflineCause$ChannelTermination 表示连接中断,再核节点 config.xml 的 launcher。macOS 的 JNLP inbound WebSocket agent 需在 Mac 本机恢复,控制器不能直接远程启动。
  • 关联:开发运维文档的 AGC macOS 手动构建节点;不要在未进入构建阶段时归因于构建脚本。

2026-10-02 AGC 更新检查失败被显示成「当前已是最新版本」

  • 现象:「关于」页手动检查更新,在清单 404、渠道缺少当前平台条目、网络失败或签名校验失败时仍显示「当前已是最新版本」。
  • 根因:services/appUpdate.ts 把更新插件的所有异常统一 return null(按“无更新”收口),而这个 null 同时表示“没有更新”和“检查失败”;RuntimeConfigDialog 里对应的 catch 分支因此永远不可达,失败被兜成成功。
  • 处理:新增结果型入口 checkForAppUpdateResult()(available / current / failed / disabled),失败保留插件返回的原始原因;checkForAppUpdate() 退化为启动期投影(失败仍静默、不阻塞启动、不动更新提示);「关于」页按结果分别显示,失败显示具体原因,渠道未启用不再冒充“已是最新”。
  • 验证:apps/ai-game-creator-shell/tests/appUpdate.test.ts 9 passed(新增失败/已是最新/有更新/未启用四组用例,并保留启动期静默断言);npx tsc --noEmit 通过。
  • 关联:apps/ai-game-creator-shell/src/services/appUpdate.ts、apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx、apps/ai-game-creator-shell/tests/appUpdate.test.ts。

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。

api-server libcurl / OpenSSL 符号版本不匹配会导致启动失败

  • 症状:release 部署新 api-server 后服务反复 exit-code,LD_TRACE_LOADED_OBJECTS=1 /opt/genarrative/current/api-server 或 ldd 报 /lib/x86_64-linux-gnu/libssl.so.3: version 'OPENSSL_3.2.0' not found。
  • 根因:platform-image 使用 libcurl 后,Linux release 构建产物可能直接要求 OPENSSL_3.2.0 符号;Ubuntu 24.04 apt 默认 OpenSSL 仍是 3.0.13,不能满足该符号版本。
  • 处理:Genarrative-Server-Provision 独立安装 OpenSSL 3.2.0 到 /opt/genarrative/openssl-3.2.0,并只通过 genarrative-api.service 的 LD_LIBRARY_PATH=/opt/genarrative/openssl-3.2.0/lib64:/opt/genarrative/openssl-3.2.0/lib 给 api-server 使用,避免替换系统 OpenSSL。
  • 关联:scripts/jenkins-server-provision.sh。

后端、数据与接口契约

External 动画轮询的精简引用不等于完整序列记录

  • 动画 completed 结果通过顶层 frames 和 durationSeconds 提供有序帧与秒级时长;内嵌 resource/asset 只提供精简引用。正式 imageSequenceFrames/imageSequenceDurationMs 从完整项目或素材库按返回的 ID 读取,后者时长单位为毫秒。内嵌引用没有这两个字段不能据此判定持久化丢帧,也不能用首帧重复创建动画素材。
  • 排查时分别核对真实精简函数、正式记录和 External v1 OpenAPI;Python helper 的模拟响应必须遵循同一结构,不能用虚构的嵌套完整记录证明轮询契约成立。

2026-10-05 generationInputs 的保存与复用不等于自动生效或完整透传

  • 现象 / 根因:调用方把构图等要求只写入 generationInputs.artSpec,但实际生图输入没有这些要求;把 JsonValue 和“可复用规范”误读为服务端会自动组装提示词、完整保留全部元数据或自动用于下一次生成。
  • 现行边界:整个 generationInputs 承载生成上下文、来源和应用元数据,保存规则取决于接口。场景与音效重建配方;图集原图可保存自定义字段,透明图集与切片另建处理阶段 / 来源元数据。已知字段仍有实际消费者,例如规范图的“游戏类型”、V2 面板恢复字段和客户端来源标记,不能把整个对象描述为无业务作用。
  • 排查 / 使用:同时核对正式请求字段、当前接口的重建 / 清理逻辑,以及最终资源 / 素材记录;重要要求必须进入 prompt 或场景结构化参数。项目与素材库读取可取得服务端实际保存的上下文,但它不等于完整 HTTP 重放载荷;重试仍保存原始请求和幂等键。
  • AGC 美术包:图集新请求与普通图集统一提交 trim 后原始需求,客户端不拼接固定要求;首张图生成前校验原始需求 1~1000 个 Unicode 码点,服务端模板不占额度。游戏类型及类别映射由服务端保留。旧活动请求不做迁移或续跑兼容,失败须释放客户端忙碌状态,不能自动重发付费请求。必须验证实际 HTTP 载荷,不能只测试 artSpec 包含文案。
  • 权威说明:External v1 OpenAPI 与 API 指南的 Generation Inputs Metadata。本条记录现有行为,不引入 API、存储或 UI 行为变更。

2026-10-05 给 shared-contracts 请求 DTO 加字段:#[serde(default)] 救不了 Rust 结构体字面量,AGC 壳编译失败会伪装成 npm run dev:all 起不来

  • 原因:#[serde(default)] 只兼容反序列化缺字段,不补 Rust 结构体字面量。AGC 壳是独立 Cargo workspace,server-rs crate 测试通过不代表壳能编译;tauri dev watcher 编译失败仍可能保持运行,表现成“窗口没启动”。
  • 处理:修改请求 DTO 同步更新所有构造点,并验证 AGC 壳;先准备 bundled resources,再以当前 dev/release 相同的编辑器 feature 组合检查,完整入口为 npm run ai-game-creator-shell:check:rust。默认 feature 与已 staging 插件不匹配时的 build.rs 错误,应按资源前置条件处理。
  • 当前契约:发布价格按作者选择透传;缺省价格仅兼容旧客户端按免费处理,不能把历史 price_mud_points:0 写成当前 UI 规则。
  • 关联:shared-contracts/src/game_distribution.rs、src-tauri/src/game_distribution_publish.rs、scripts/check-native-shells.mjs。

2026-10-01 用户输错一次密码被当成"客户端出问题了"引导上报

  • 原因:Tauri Err(String) 会丢失错误类型,输错密码等业务拒绝因此被当成系统异常进入报告池;Rust 把 typed 错误再次折为 String 同样破坏分流。
  • 处理:认证命令返回 ClientAuthError,前端经 invokeClientAuth/ClientAuthErrorWrapper 按变体处理。业务/会话拒绝只反馈用户;系统及非结构化拒绝原样抛出,经全局 unhandledrejection 上报一次。refresh 非权威失败也不得降级成字符串。
  • 关联:auth_session.rs、src/services/clientAuth.ts、errorReporting.ts、platformSession.ts;错误报告合同。

2026-09-24 AGC 生成路由迁移必须核对账号队列契约

  • AGC 普通账号会把 external editor 路由映射到站内入口;只验证 API Key 路由不足以证明客户端链路可用。
  • 站内入口必须读取客户端持久化的 Idempotency-Key,场景配方重建须保留精确的 generationInputs.source = ai-game-creator-client 标记。否则可能按请求 ID 重复入队,且普通队列 consumer 不保存客户端轮询所需的可下载 result。
  • 场景其余执行字段和引用身份仍由服务端重建,不整体信任调用方 generationInputs。验证应覆盖来源标记经过组装、队列清理和结果序列化的完整纯逻辑链路,以及账号路由的幂等键校验。

AGC 画布绑定前置查询不能取全量项目列表

  • 现象:dev 上「AI 生成图片」连续失败,卡片显示 解析读取外部画布项目响应失败:error decoding response body,每条恰好 1 分 00 秒(三条同因,各自独立计时)。
  • 原因:绑定前置的 GET /api/(external/v1/)editor/projects 缺省 view=full,会把账号下每个项目的画布与全量资源一起返回(19 个大项目的 fixture 就已超过 4 MiB);客户端这条请求只有 60 秒预算,卡在读正文时被 reqwest 总超时打断。而 reqwest::Error 的 Display 只打印 kind,超时、正文被截断和非法 JSON 显示成同一句话,现场看不出根因。
  • 处理:只确认项目身份的消费者固定取 view=summary(站内与外部路由都支持,缺省 full 不变,未知取值失败关闭);摘要视图不做内联媒体修复、不带画布与全量资源;外部请求失败文案补 kind 语义与 source 因链,且不拼接 URL。
  • 验证:站内路由用例断言 view=summary 不回传 canvas / layers / resources 且不触发媒体修复、view=unknown 返回 400;AGC 壳用例断言失败文案不再等于 error decoding response body、补出因链且不含绝对地址。
  • 关联:server-rs/crates/api-server/src/editor_project.rs、server-rs/crates/api-server/src/external_editor_api.rs、apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs。

2026-09-15 AGC JSON API 的响应体也必须有等待上限

  • fetchClientHttp 的超时只覆盖请求到响应头返回;随后直接等待 response.text() 仍可能无限挂起。模型目录共用一个在途 Promise,响应体卡住会使后续刷新复用同一挂起请求、选择器持续忙碌。
  • 成功 JSON 与错误响应体均复用 readClientHttpResponseText 的 15 秒上限;超时后保留最后一次有效目录并释放在途请求,手动重试重新发起请求。迟到的响应不得覆盖重试获得的新目录。
  • 排查时区分接口未挂载(404)、未授权(401)、网络或响应体超时以及刷新无变化但缺少反馈;不能仅凭客户端启动 IPC 回退警告判断刷新失败原因。

2026-09-14 UI 超时围栏只能放弃等待,不能放弃结果;排队闸门不能无限等

  • 现象:登录/建项在 UI 上"超时"后报错,用户重试仍然无效;界面停在原页面,而后端/Runner 其实已经接受了这次操作(登录后本机登录态已装好、项目目录已建好)。
  • 原因:两个独立缺陷叠加。(1) withAuthCheckTimeout 一类围栏用 Promise.race 只让界面提前失败,底层 native mutation 仍在队列里跑;而队列尾是"无限等上一次完成"的串接,一次卡住的 invoke 会让之后每次登录/退出都排在它后面(故障注入:连续两次登录只产生 1 次 install 调用)。(2) platformNativeGenerationFloorPromise 用 ??= 缓存 promise,一次瞬时读取失败被缓存成永久失败。
  • 处理:(1) 围栏超时后仍要有人接手结果——AuthenticatedClient 用尝试代次 + currentPlatformSessionGeneration() 判定,迟到成功才写回界面,绝不覆盖更新的尝试;(2) native 写入队列改成带解围期限的闸门(PLATFORM_SESSION_NATIVE_MUTATION_ABANDONMENT_MS)。普通队列"串行"看起来更安全,但本地会话写入的真正不变量在 Rust:install_platform_session_in / clear_platform_session_in 按 generation 单调拒绝更旧写入,所以渲染层只要保证新 generation 不被旧调用永久挡住即可;(3) 首页 home-create 从 deadlineMs: null 改为有兜底期限,到点用 Promise.race 返回提示字符串解围(不要抛错:首页 catch 会把错误统一压成「创建未完成,请重试」,反而丢掉"只是慢")而底层创建继续跑,迟到成功照常进项目;已建好的工作区要登记最近项目并留「打开已创建的工作区」入口。
  • 易错点:失败分支里不要用初始 operation 去覆盖已经带上 scope.projectPath 的状态——transitionClientOperation(初始 operation, ...) 会把内层写好的路径丢掉,导致"项目已建好但用户拿不到"。看门狗解围后仍要保留"底层创建未返回"标记:放行会真的建出第二个工作区;但这条只挡"再建一个",不能挡打开已有项目。
  • 验证:apps/ai-game-creator-shell/tests/appSurface.test.ts(auth.suite.ts 的 stalled install / floor 瞬时失败 / 围栏后迟到安装;home.suite.ts 的建项看门狗与设计运行时初始化失败后的恢复入口)。
  • 关联:apps/ai-game-creator-shell/src/services/platformSession.ts、src/app/AuthenticatedClient.tsx、src/features/app-shell/useHomeProjectCreation.ts、src-tauri/src/platform_session.rs、docs/【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md

派生 Debug 会让完整配置经应用状态递归进入日志

  • 现象:配置和状态当前没有直接日志调用,但新增一行 debug!(?state, ...) 或 format!("{config:?}") 就能把 JWT、后台口令、支付私钥、OSS / provider key 与 SpacetimeDB token 一次性写入日志及 OTel 留存面。
  • 原因:AppConfig、AppState 与 AppStateInner 曾使用派生 Debug;状态继续递归格式化多个含配置的 client。即使顶层状态停止下钻,SpacetimeClientConfig 及 SpacetimeClient 的独立手写路径仍会绕过顶层防线。
  • 处理:配置和聚合状态只实现封闭的手写安全摘要,不格式化任一自由字符串或含凭据的嵌套 client;SpacetimeClientConfig 独立脱敏,SpacetimeClient 只复用该安全摘要。不要以默认 info 级别或当前零调用点代替代码约束。
  • 验证:同一唯一哨兵同时填入全部凭据字段、可能带凭据的 SpacetimeDB URL 和数据库名,逐一格式化 AppConfig、AppStateInner、AppState、SpacetimeClientConfig、SpacetimeClient,断言哨兵零出现且安全运行摘要仍存在。
  • 关联:server-rs/crates/api-server/src/config.rs、server-rs/crates/api-server/src/state.rs、server-rs/crates/spacetime-client/src/active.rs、Issue #148。

timeout_at 不能替代显式的预算耗尽预检

  • 现象:给完美像素加端点级并发闸后,预算已经耗尽的请求仍然能拿到许可,白占一个名额继续去打几轮全账号 SpacetimeDB 扫描,直到下载那步才失败。
  • 原因:tokio::time::timeout_at 会先 poll 一次内层 future 再判超时。信号量有空闲许可时 acquire_owned() 首次 poll 就绪,于是即使 deadline 早已过去,返回的仍是 Ok(Ok(permit)) 而不是超时。既有 acquire_editor_pixel_art_cpu_permit 里那句 if Instant::now() >= processing_deadline 正是为此存在,新写的许可函数漏掉后被单测抓出。
  • 处理:所有「先判预算、再等资源」的获取函数都必须在 timeout_at 之前显式判一次 Instant::now() >= deadline 并直接返回超时错误;这句不是冗余防御。同理,进入排队计数之前也要先做这个预检,避免为注定失败的请求占用队列名额。
  • 验证:在有空闲许可时用已过期的 deadline 调用获取函数,只断言返回 504 而不是许可;504 已足以证明显式预检没有被 timeout_at 的首次 poll 绕过。禁止在该用例里读取进程级队列 Atomic 的 before/after;相对断言同样会被并行测试插入。仅靠「信号量占满时超时」的用例发现不了这个问题。
  • 关联:server-rs/crates/api-server/src/editor_project.rs(acquire_editor_pixel_art_snap_permit、acquire_editor_pixel_art_cpu_permit)。

有界等待队列的计数递减必须写在 Drop 里

  • 现象:给同步端点加「最多 N 个等待者」的保险丝时,若把计数递减写在正常返回路径上,客户端断连或超时触发会让等待中的 future 被丢弃而跳过递减;计数只增不减,最终队列永久判定为满,接口对所有人返回 503 且不会自愈。
  • 原因:Rust 的 async future 可以在任意 await 点被取消,取消时只保证 Drop 会跑,不保证后续代码会执行。有界队列的入场与离场天然不对称。
  • 处理:把递增封进一个 guard 结构体,递减放在它的 Drop 实现里;递增本身用 fetch_update 的 CAS,不能用「先读后加」——两个线程同时读到 max - 1 各自加一就会越界。拿到资源后立即 drop(guard) 让出队列名额,不要让它跟着许可一起活到请求结束。
  • 验证:单测覆盖 CAS 边界(满了返回失败且计数不越界、上限为 0 时任何进入都失败),并由独立用例覆盖 guard 离开作用域后的计数归还。预算耗尽路径只断言 504,不得通过另一个测试也会修改的进程级 static before/after 来推断“未入队”,也不得用串行锁或 --test-threads=1 掩盖隔离问题。
  • 关联:server-rs/crates/api-server/src/editor_project.rs(try_enter_bounded_queue、EditorPixelArtSnapQueueGuard)。

phase 上报的业务拒绝与传输失败不能共用字符串错误

  • 现象:provider 已经返回并保存原图,worker 上报 processing 时一次断连或超时就直接把任务判为失败;或者为了规避误杀而重试所有错误,导致 stale lease 的旧 worker 继续执行后处理。
  • 原因:phase procedure 的 lease / fencing 业务拒绝与 SDK 建连、断连、超时错误被压成同一种字符串错误,调用方无法可靠决定是否重试;按中文或 SDK 文案匹配会在错误文本变化后失效。
  • 处理:procedure 返回结构化 LeaseFencingRejected / OtherRejected,typed client 再把模块拒绝与 RPC 错误分开。LeaseFencingRejected 立即终止,OtherRejected 以及 SDK 的 Procedure / Runtime 错误不重试;只有 Build / ConnectDropped / Timeout 在同一 job attempt 内重试一次。编辑器 job 固定 max_attempts=1,第二次传输失败后进入 failed,不回 pending、不重新调用 provider。不得让 phase 上报错误落入“后处理失败保留原图”的降级分支。
  • 验证:分别覆盖 lease / fencing 拒绝、其它拒绝、建连、断连、超时和第二次失败,确认最多调用两次;同时断言角色、图标和 UI 的原图降级只包住透明背景处理,不包住 phase 上报。
  • 关联:server-rs/crates/spacetime-module/src/external_generation.rs、server-rs/crates/spacetime-client/src/external_generation.rs、server-rs/crates/api-server/src/editor_project.rs、docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md。

禁止 Data URL 持久化时不要漏掉异步任务 JSON

  • 原因:异步入队把请求 Data URL 整体写进任务 JSON,会放大数据库/BFF 内存;列表先收全行再截断仍会多次持有大 payload。
  • 处理:编辑器队列在 API 与 SpacetimeDB 两层递归拒绝 data: / blob: 并限制字节数;媒体以 objectKey/resourceId/assetId 引用,本地派生先上传。列表、详情、ack 只走有界摘要,错误也清内联媒体并限长;门禁不得扩大到尚需先资源化的其它契约。
  • 辨识:幂等去背景内部核对 dedupe key 与请求指纹 仍需按主键与 owner 读取完整原任务,不能用已删字段的 summary 代替;该例外不向 UI 开放。
  • 边界:历史压缩不得改写 pending/running 活动任务。
  • 关联:editor_generation_queue.rs、spacetime-module/src/external_generation.rs、api-server/src/external_generation.rs;队列与历史维护合同。

后台素材查询不要用 SQL 直查 editor_asset

  • 后台审核与素材查询的图片预览若要显示像素化原图,应由后台 read model 在 sourceResourceId 关联的项目资源上预先透传原图媒体引用,再复用管理员换签;不要让 admin-web 直接查询私有 editor_project_resource。

  • 现象:后台“素材查询”报 HTTP 400:no such table: editor_asset. If the table exists, it may be marked private.。

  • 原因:editor_asset 是私有 SpacetimeDB 表,后台 SQL / schema HTTP 查询面看不到私有表;即使 api-server 有后台身份,也不能把私有表当 Dashboard SQL 表直接查。

  • 处理:后台素材查询走 spacetime-module 内的 admin_list_editor_assets_and_return procedure,由 spacetime-client typed facade 调用后再在 api-server 映射作者展示名和陶泥号。新增类似后台只读能力时,优先补窄 procedure / read model,不要复用 fetch_admin_dashboard_rows 直查私有源表。

  • 验证:cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml、cargo check -p api-server --manifest-path server-rs/Cargo.toml、npm run check:spacetime-schema。

  • 关联:server-rs/crates/spacetime-module/src/editor_project_storage.rs、server-rs/crates/spacetime-client/src/editor_project.rs、server-rs/crates/api-server/src/admin.rs。

后台素材查询要在分页前归一用户、游标和派生任务

  • 原因:素材 owner 是内部 user_id,公开 SY 陶泥号不能直接过滤;SpacetimeDB 游标的 seconds.microsZ 不能只按整数微秒解析;派生 taskId 与原图集来源不同,直接分组会割裂链路。
  • 处理:API 调 procedure 前解析公开身份;游标接受整数微秒、seconds.microsZ/RFC3339,非法输入返回 400,编码失败报服务错误,不能伪装末页。保留真实 taskId,以可信 provenance 固化 group_task_id,不把展示分组反写来源。
  • 边界:原批完整性由每片 expected count 与不可逆 cohort 完成事实证明,不能按删减后的剩余行数猜;失败与重复拆分批次独立分页。跨项目来源与删除前固化须核对真实 lineage,无可信来源的新批次不得读取任意资源元数据走 legacy 回退。
  • 关联:AdminEditorAssetQueryPage.tsx、api-server/src/admin.rs、spacetime-module/src/editor_project_storage.rs;后台素材查询合同。

画板外部生成排队超时不是失败

  • 现象:画板发起付费图片生成后,前端弹出 生成任务仍在队列中,请稍后刷新画布查看结果,但后端任务仍在队列或执行中,后续可能正常完成。
  • 原因:画板生成已经接入后端外部生成任务队列,queued / running 是正式任务状态;旧前端轮询等待窗口到期时直接抛错,导致正常排队被提交流程 catch 成失败 UI。
  • 处理:waitForEditorGenerationQueue 等待超时只返回“仍在后端继续执行”,调用方停止本次前端等待并保留生成中状态;只有后端任务终态为 failed 才展示失败。
  • 验证:画板生成 workflow 测试覆盖 queueState 持续 running 到前端等待窗口结束时,不进入 failed、不显示该排队文案、不添加本地临时结果层。
  • 关联:src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts、src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx。

资产换签不能把 generated 前缀当成 objectKey 授权

  • 现象:主站或 External API 只要拿到另一个账号的 generated objectKey 就能换签,已登记的私有对象因为 key 同时命中 legacy 前缀而被匿名读取,或者 /api/assets/read-url 已拒绝但 /api/assets/read-bytes 仍能读出原始字节;后台资源预览为解决跨账号读取又误把主站入口整体放开。
  • 原因:legacyPublicPath 与 objectKey 代表两种不同信任边界。前者仅用于未登记历史公开作品兼容,后者是正式对象引用;只检查 generated 前缀、或在查询 asset_object metadata 前直接接受 legacy 白名单,都不能证明对象公开或属于调用方。签名 URL 和 bytes proxy 如果各写一套判断也容易漂移。
  • 处理:read-url 与 read-bytes 必须共用 authorize_asset_read_target,先按配置 bucket / 精确 key 查询 asset_object;metadata 一旦存在,即使 key 命中 legacy 前缀,也严格执行 PublicRead / owner ACL。只有 metadata 不存在且显式 legacyPublicPath 命中 platform_oss::LEGACY_PUBLIC_PREFIXES 时才允许匿名兼容;普通 objectKey 必须登记。唯一窄例外是历史精选活动卡:global 配置已启用、请求 key 位于 generated-character-drafts/editor/showcase-campaign/ 且与当前 image_object_key 精确匹配时,可在 metadata 缺失期间派生公开读取,禁用或换图后旧 key 立即失效;新上传活动卡仍必须 confirm。External read-url 使用 API Key owner。主站和 External 的 object confirm owner 必须来自认证主体,同 bucket / key 已登记后不能改变 owner,不能让请求体 owner 接管对象。后台跨 owner 换签只用于管理员资源预览,成功后以 admin_asset_read_url 持久化管理员 subject、请求对象和有效期,且不得记录 signed URL;不能为此把 admin 能力下沉到主站入口。无权访问统一返回不存在,避免泄露对象是否存在。
  • 验证:覆盖未登记 curated legacy public path、命中 legacy 前缀但已有私有 metadata、未登记普通 objectKey、活动卡当前/禁用/替换/越目录 exact key、公开对象、本人私有对象、跨 owner、匿名私有、External owner、confirm owner 不可变和 admin-only endpoint;对 read-url 与 read-bytes 使用同一组授权矩阵,并断言 Admin 成功换签会生成不含 signed URL 的管理员主体审计事件。
  • 关联:server-rs/crates/api-server/src/assets.rs、server-rs/crates/api-server/src/external_assets_api.rs、server-rs/crates/api-server/src/admin.rs、server-rs/crates/api-server/src/modules/admin.rs。

精选活动卡上传成功但网站空图先查对象确认和 exact grant

  • 现象:后台精选活动卡能保存标题、作者、尺寸和图片地址,GET /api/editor/showcase/resources 也返回已启用 campaign,但网站卡片只有占位区域,没有 <img>;Network 中活动卡的 /api/assets/read-url?objectKey=... 返回 404 资源不存在或无权访问。
  • 原因:活动卡旧上传链路只完成 signed POST 并保存 imageSrc + imageObjectKey,没有调用 object confirm;同时公开授权只扫描普通 editor_showcase_asset,没有识别当前活动卡。前端见到 imageObjectKey 后会优先走正式 objectKey 换签,失败时按安全规则保持空 src,不回退裸 private 路径。
  • 处理:后台上传必须按 ticket -> OSS POST -> /admin/api/editor-showcase/campaign/image-upload-confirm -> 写回表单执行,confirm 复用统一 OSS HEAD、bucket/长度校验和 asset_object upsert,并由管理员会话绑定 owner、强制 private/固定 asset kind/活动卡专用目录。读取 procedure 在同一事务中对当前 enabled global campaign 的专用目录 exact key 派生授权,使历史未登记当前卡无需重新上传即可恢复;api-server 仅接受 procedure 明确返回的这一 grant,其他未登记 objectKey 继续 404。
  • 验证:SpacetimeDB 测试覆盖 current key、disabled、missing key、replaced old key、越目录 key 和普通精选 grant;api-server 测试覆盖 metadata 缺失时 exact grant 可读、无 grant 仍 404、confirm 路径/MIME/大小/固定 private 约束;admin-web 测试锁定 ticket -> OSS -> confirm 顺序。真实浏览器应看到活动卡图片,Network 中 objectKey 换签返回 200,禁用或换图后旧 key 返回 404。
  • 关联:apps/admin-web/src/api/adminApiClient.ts、server-rs/crates/api-server/src/admin.rs、server-rs/crates/api-server/src/assets.rs、server-rs/crates/spacetime-module/src/asset_metadata/objects.rs、server-rs/crates/spacetime-module/src/editor_project_storage.rs。

私有兑换码不适用先查同手机号重复账号

  • 现象:后台把私有兑换码配给某个陶泥号或手机号后,用户用同一手机号登录兑换仍提示 该兑换码不适用于当前账号。
  • 原因:认证表里可能存在同一手机号的多条 user_account。如果认证工作集重建 phone_to_user_id 时让 user_account.phone_number_e164 后写覆盖前写,当前登录态会漂到没有 auth_identity 的重复账号,而兑换码白名单仍指向另一个内部 user_id。
  • 处理:重建认证工作集时以 typed AuthStoreProjectionView 从 user_account / auth_identity / refresh_session 恢复;手机号索引以 auth_identity(provider="phone") 指向的账号为权威,user_account.phone_number_e164 只补没有 identity 的手机号;auth_store_snapshot 表和旧 JSON procedure 已删除,Bearer / refresh session 本进程未命中时不要再从 SpacetimeDB 导出整包状态刷新内存。线上止血先核对失败请求附近的 current session user_id 与兑换码 allowed_user_ids,不要只看手机号展示值。
  • 约束:auth_identity 只保存登录入口身份键;手机号、昵称和头像的正式资料真相在 user_account.phone_number_e164 / display_name / avatar_url。旧 auth_identity.phone_e164 / display_name / avatar_url 只能作为历史回填来源,不能继续让新写入依赖这些列。
  • 验证:cargo test -p spacetime-module auth_export -- --nocapture 应覆盖同手机号重复账号时手机号索引优先指向有 phone identity 的账号;api-server 中不应再存在运行期 refresh_auth_store_from_spacetime 调用。
  • 关联:server-rs/crates/spacetime-module/src/auth/procedures.rs、server-rs/crates/spacetime-module/src/auth/tables.rs、server-rs/crates/module-auth/src/lib.rs。

专用生成契约不能被通用生成接口和任务摘要绕过

  • 现象:专用场景接口要求结构化 sceneContent + stylePreset,但调用方仍可向通用图片接口传 kind = scene 或 assetKind = scene,用任意完整 Prompt 生成并持久化正式场景;合法场景入队后,任务侧栏还可能显示后端完整规则文本和通用“生成图片”标题,空白素材名则可能回退成完整 Prompt。
  • 原因:专用 handler 内部复用了通用图片 payload、队列和 Worker,但公开通用 HTTP handler 没有限制专用身份;任务摘要又无条件优先提取 payload 顶层 prompt,素材名默认值只处理了字段省略,没有处理空白字符串。
  • 处理:公开通用 handler 拒绝专用 kind / assetKind,专用 handler 仍可直接调用内部共享执行函数;队列投影按 kind = scene 从权威 generationInputs.fields[画面内容] 派生标题和摘要,缺字段时失败关闭而不是回退内部 Prompt,并重新计算历史缓存;专用素材名统一把省略和空白收口为产品默认值。
  • 验证:路由测试先证明旁路会越过 HTTP 边界,再断言两种旁路均返回 400 且指向专用端点;摘要测试覆盖新任务、历史错误缓存和缺少画面内容三种情况;标签测试覆盖省略、空白、自定义和 80 字上限。
  • 关联:server-rs/crates/api-server/src/editor_project.rs、server-rs/crates/spacetime-module/src/external_generation.rs、docs/technical/【技术方案】图片画布游戏场景生成链路-2026-08-04.md。

图片编辑主来源不能接受 objectKey 或请求类型

  • 现象:调用方可把 objectKey、URL 或 Data URL 当作主来源,再用请求 assetKind 或另一个允许编辑的目标图层为禁止类型“借壳”;无目标图层时,后端还会扫描账号全部项目和素材库。
  • 原因:HTTP DTO 同时承担外部请求与队列载荷,来源身份、存储定位和类型真相混在 sourceImageSrc/sourceResourceId/assetKind 中;worker 没有按业务 ID 复核入队后的身份漂移。
  • 处理:站内与 External v1 API 调用方只提交必填 sourceReferenceId,且只接受当前账号项目资源 ID 或素材 ID;上传对象必须先登记。后端按两张表主键分别窄查,双表同 ID 时失败关闭,objectKey 仅作为服务端解析结果。目标绑定优先比较双方 assetObjectId,缺失才比较 canonical (bucket, objectKey),并校验双方默认类型一致。队列保存版本化解析快照,worker 执行前再次定点解析;旧任务只把既有资源 ID 或旧来源字符串本身作为业务 ID 尝试迁移,禁止 objectKey 反查和旧 assetKind 真相回退。
  • 验证:覆盖资源 ID、素材 ID、双表冲突、跨账号、raw objectKey/URL/Data URL/Blob URL、旧字段、禁止类型、目标对象与类型冲突、快照漂移、旧任务迁移、红框图辅助引用,以及 Canvas Agent 缺少 reference_id。
  • 关联:server-rs/crates/spacetime-module/src/editor_project_storage.rs、server-rs/crates/api-server/src/editor_project.rs、server-rs/crates/api-server/src/external_generation_worker.rs、src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts、docs/openapi/genarrative-external-v1.openapi.json。

外部生成 worker 重领必须按 claim attempt 隔离并持久结算

  • 现象:同一个外部生成 job 在 worker 崩溃或 lease 过期后重领,可能出现旧 attempt 和新 attempt 都扣费,或者旧 attempt 已退款后新 attempt 因稳定 ledger 被当成幂等而免费执行。
  • 原因:只按业务资源 ID 或 job ID 生成稳定 ledger 无法区分 claim;仅在新 attempt 开始时“先查旧 consume、存在则退款”仍有竞态,旧 consume RPC 可能在检查之后才提交。
  • 处理:扣退费 ledger 固定包含 job_id + claim_attempt,每次重领先结算所有旧 attempt,再扣当前 attempt。结算必须在 SpacetimeDB asset_operation_wallet_settlement 持久化:旧 consume 已存在时原子退款;尚不存在时写取消 intent。任何迟到 consume 在同一事务内看到 intent 后失败关闭。重复 ledger 必须核对用户、金额和来源,不能只按 ID 存在就返回成功。claim 处理 lease 已过期的 running job 时还必须先比较 attempt 与 max_attempts:未耗尽才递增并返回 worker;最终 attempt 已耗尽时在同一事务内把 job 置为 failed、清空 lease、写完成时间和失败事件,并按当前 attempt 退款或写取消 intent,绝不能再次返回 provider executor。
  • 验证:cargo test -p spacetime-module asset_operation 覆盖缺 consume 时写 intent、冲突结算拒绝和退款配对;cargo test -p spacetime-module external_generation::tests:: 覆盖未耗尽 lease 可重领、最终 attempt 只终态收口且不再递增;cargo test -p spacetime-module wallet_idempotent_replay 覆盖冲突重放;cargo test -p api-server asset_billing 覆盖崩溃重领、重复结算和当前 attempt 扣费。
  • 关联:server-rs/crates/api-server/src/asset_billing.rs、server-rs/crates/spacetime-module/src/runtime/profile.rs、docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md。

外部生成 worker 不应等待 HTTP 认证投影恢复

  • 现象:genarrative-external-generation-worker@1.service 在 systemd 中显示 active,但 external_generation_job 长时间保持 pending;worker 日志每 5 秒出现认证投影或公开 read model 订阅失败。
  • 原因:独立 worker / controller 是非 HTTP 角色,不承接用户登录态恢复;如果启动路径复用 HTTP api-server 的认证投影恢复,SpacetimeDB 认证投影或公开 read model 漂移会把 worker claim 循环挡在启动前。
  • 处理:GENARRATIVE_PROCESS_ROLE=external-generation-worker 和 external-generation-controller 启动时只构建空 auth store 的 AppState,不调用 SpacetimeDB 认证投影导出;只有 api / all 这类 HTTP 角色需要在启动时恢复认证投影并在依赖不可用时重试或进入 503 降级。
  • 验证:重启 worker 后日志应先出现“非 HTTP 进程跳过 SpacetimeDB 认证投影恢复”,随后出现 external generation worker 已启动;同一时间窗口不应再因为认证投影恢复失败而阻止 job claim。HTTP api-server 的认证恢复日志和 503 降级语义保持不变。
  • 关联:server-rs/crates/api-server/src/main.rs、server-rs/crates/api-server/src/external_generation_worker.rs、server-rs/crates/api-server/src/external_generation_worker_controller.rs、docs/【开发运维】本地开发验证与生产运维-2026-05-15.md。

外部生成 worker 业务写回必须同事务校验 lease guard

  • 现象:worker complete/fail 已校验 worker_id + lease_token,但如果玩法 session / work profile 写回在此之前单独调用,过期 worker 仍可能先写入业务状态,随后才在 job complete/fail 阶段失败;带计费包装的旧 worker 还可能因为 stale guard 错误触发补偿退款。
  • 原因:队列状态栅栏只保护 external_generation_job 自身,不会自动保护玩法 procedure。业务写回必须自己带 claim 后的 job_id / worker_id / lease_token,并在同一个 SpacetimeDB transaction 内校验 job 仍为 running、lease 未过期、job kind、owner 和 source entity 匹配。
  • 处理:拼图首图 worker 的前置 compile_puzzle_agent_draft、save_puzzle_generated_images、save_puzzle_ui_background、mark_puzzle_draft_generation_failed 和 mark_puzzle_level_generation_failed 已接入 external_generation_job lease guard;api-server 的资产扣费包装遇到这类 stale worker lease guard 错误时不执行补偿退款,错误文本包含 external_generation_job 当前不是 running 状态 或 external_generation_job 不存在 时也按 stale guard 处理。inline 模式只允许 job_id / worker_id / lease_token 三项同时为空,半空 guard 仍拒绝。后续迁移其它玩法 worker 时必须复用该模式,不能只在 worker 进程内保存一份 token。
  • 验证:cargo test -p api-server external_generation_worker --manifest-path server-rs/Cargo.toml、cargo test -p api-server asset_operation_billing_does_not_refund_stale_worker_lease_errors --manifest-path server-rs/Cargo.toml、cargo check -p api-server --manifest-path server-rs/Cargo.toml。
  • 关联:server-rs/crates/spacetime-module/src/external_generation.rs、server-rs/crates/spacetime-module/src/puzzle.rs、server-rs/crates/api-server/src/external_generation_worker.rs、server-rs/crates/api-server/src/asset_billing.rs、docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md。

外部生成 worker 核心业务写回失败不能完成 job

  • 现象:worker 已经生成图片并拿到本地合成 session 快照,但 SpacetimeDB 业务写回因连接、旧 wasm 或 lease guard 失败没有真实落库;如果此时仍把 external_generation_job 标成 completed,前端只会看到队列完成而 session 长时间不变化,后续也没有 worker 会重领修复。
  • 原因:同步 HTTP handler 的“外部 provider 已成功但 SpacetimeDB 短暂不可用时返回内存快照”降级语义,不能直接搬进异步 worker。worker 的完成状态必须代表核心业务事实已经持久化。
  • 处理:worker 路径的 save_puzzle_generated_images / save_puzzle_ui_background 等核心业务写回失败时直接返回错误;只有核心写回已经成功后的非关键投影回写才允许降级记录 warning。业务失败态也必须先写回 session / work profile,写回成功后才允许把队列 job 标为 failed;失败态未写回时保留租约,等待 lease 过期后重领。生产首装和首次 API deploy 都必须至少启用一个 worker 实例,例如 systemctl enable --now genarrative-external-generation-worker@1.service。
  • 验证:cargo check -p api-server --manifest-path server-rs/Cargo.toml、cargo test -p api-server asset_operation_billing_does_not_refund_stale_worker_lease_errors --manifest-path server-rs/Cargo.toml,并在 smoke 时确认 queued 任务被 worker 消费后 session 真实更新。
  • 关联:server-rs/crates/api-server/src/puzzle/draft.rs、server-rs/crates/api-server/src/puzzle/generation.rs、server-rs/crates/api-server/src/external_generation_worker.rs、docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md。

Pingora Brotli 不能只看 Content-Encoding

  • 现象:在 pingora-gateway 中把 GENARRATIVE_PINGORA_GATEWAY_COMPRESSION_ALGORITHMS 试验性改成 gzip,br 后,Accept-Encoding: br, gzip 的响应会带 Content-Encoding: br,但 Node brotliDecompressSync(...) 报 unexpected end of file。
  • 原因:Pingora 0.8.1 的 Brotli compressor 路径虽然存在,但端到端输出不能被 Node 按完整 Brotli 流解压;只断言响应头会误判为可用。
  • 处理:当前 Pingora shadow 只允许 GENARRATIVE_PINGORA_GATEWAY_COMPRESSION_ALGORITHMS=gzip,在进入 Pingora compression 模块前把下游 Accept-Encoding 收敛为 gzip。Brotli 继续由 Nginx / 前置代理承担,直到补齐可解压的端到端门禁后再评估迁移。
  • 验证:npm run check:pingora-gateway-smoke 必须覆盖小响应不压缩、图片资源不压缩、大响应 Accept-Encoding: gzip 和 Accept-Encoding: br, gzip 都返回可解压的 gzip;GENARRATIVE_PINGORA_GATEWAY_COMPRESSION_ALGORITHMS=br 必须启动失败,GENARRATIVE_PINGORA_GATEWAY_GZIP_MIN_LENGTH_BYTES=0 也必须启动失败。
  • 关联:server-rs/crates/pingora-gateway/src/main.rs、scripts/check-pingora-gateway-smoke.mjs、docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md、deploy/nginx/README.md。

SpacetimeDB 45 秒超时要看 api-server 记录的阶段

  • 现象:release 上 Nginx 能立刻连到 api-server,但关键业务请求在约 GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS 后返回 502 / 504。
  • 原因:旧日志只能看到 HTTP 总耗时和最终状态,无法区分卡在连接池、SDK 建连、等待 on_connect、订阅 read model、等待 procedure / reducer 回调还是本地订阅 cache 读取。
  • 处理:spacetime-client 内置阶段化健康检查和失败日志;/readyz 用 GENARRATIVE_SPACETIME_HEALTH_CHECK_TIMEOUT_SECONDS 短窗口检查 SpacetimeDB 连接租约,业务失败日志包含 operation_kind、operation_name、spacetime_stage、elapsed_ms。
  • 验证:/readyz 失败时看 details.spacetime.stage;业务请求超时时查 journalctl -u genarrative-api.service 中同一时间窗口的 SpacetimeDB client operation failed,优先按 pool_acquire、connect_build、connect_handshake、read_model_subscribe、procedure_result、reducer_result、read_cache 分阶段处理。
  • 关联:server-rs/crates/spacetime-client/src/lib.rs、server-rs/crates/api-server/src/health.rs、docs/【开发运维】本地开发验证与生产运维-2026-05-15.md。

tracking outbox 封存与确认删除保证至少一次投递

  • 原因:请求线程同步等批量落库会拖长 route tracking;成功后 truncate 又扩大崩溃丢事件窗口。
  • 处理:达到数量阈值立即封存 active 并换新文件,时间阈值兜底;后台异步 flush sealed,成功确认后删除,失败保留重试,坏行文件隔离 corrupt-*,磁盘上限只作保护。至少一次重复由 tracking_event.event_id 幂等跳过。
  • 核验:数据库不可用时普通 route 仍返回且 sealed 保留,恢复后正确入库并删 sealed;不能用清空文件代替确认删除。
  • 关联:server-rs/crates/api-server/src/tracking_outbox.rs;开发运维。

容器高 VU 下 /healthz RSS 尖峰先查 Axum state 深拷贝

  • 现象:高并发 keepalive 下仅请求 /healthz 也出现 RSS 尖峰,与业务 procedure、cache 和请求日志无关。
  • 原因:Axum/Hyper 会在 router/service/connection 路径频繁 clone state;大结构体 AppState 的深拷贝因并发放大成内存高水位。
  • 处理:AppState 保持 Arc<AppStateInner> 浅拷贝壳,共享字段放入 inner。用容器内直连 /healthz 压测,观察进程 RSS 与 cgroup memory,先隔离 state clone 再查业务链路。
  • 关联:server-rs/crates/api-server/src/state.rs、deploy/container/README.md。

忘记密码后仍提示手机号或密码错误先查认证投影同步

  • 现象:用户通过“忘记密码”重设密码后,接口返回成功或页面进入登录态,但再次使用新密码登录仍提示“手机号或密码错误”;重启后还可能出现 Bearer JWT 版本已失效,日志里的 token version 与本地快照不一致。
  • 原因:重置/修改密码会更新 password_hash、password_login_enabled 和 token_version,如果 API 层只更新本地 InMemoryAuthStore,没有调用 sync_auth_store_tables_to_spacetime(),api-server 重启时可能从旧的 SpacetimeDB 正式认证表恢复账号状态。
  • 处理:POST /api/auth/password/change 与 POST /api/auth/password/reset 成功后必须同步正式认证表。2026-07-01 起,auth_store_snapshot 表和旧 JSON procedure 已删除;认证工作集只通过 typed projection 同步 user_account / auth_identity / refresh_session。认证创建、登录会话、刷新、退出、改密、重置密码、绑定和资料变更等写操作必须在返回客户端前成功同步 SpacetimeDB;同步失败时接口返回错误,不允许把只存在于当前进程内存的账号或会话当成成功结果。新用户注册奖励、邀请码绑定和登录埋点必须排在认证同步成功之后,避免认证没落库时先写出钱包或邀请关系。
  • 验证:执行 cargo test -p module-auth password --manifest-path server-rs/Cargo.toml 与 cargo test -p api-server password --manifest-path server-rs/Cargo.toml;手测时重设密码后旧密码应失败,新密码应成功,重启后仍应保持。
  • 关联:server-rs/crates/api-server/src/password_management.rs、server-rs/crates/api-server/src/state.rs、docs/technical/PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md。

密码登录失败且短信登录提示手机号已存在先查孤儿手机号索引

  • 现象:老账号用密码登录提示“手机号或密码错误”,改用短信验证码登录又提示“手机号已存在 / 已注册”,用户卡在既不能登录也不能重新创建的状态。
  • 原因:历史版本或停服务时认证同步不完整,可能在 SpacetimeDB auth_identity(provider=phone) 或旧 module-auth 快照里留下 phone_to_user_id 映射,但对应 user_account / users_by_username 用户行已经不存在。密码登录按手机号索引找不到真实用户,短信登录尝试创建新用户时又被孤儿手机号索引挡住。
  • 处理:export_auth_store_projection_from_tables 只导出正式认证表 projection;module-auth 从 projection 恢复时必须丢弃指向不存在 user_account 的 identity、union 索引和 refresh session。运行时创建手机号用户前若发现手机号映射指向不存在的用户,应删除孤儿映射后继续创建,避免死锁态继续扩散。
  • 验证:cargo test -p module-auth projection --manifest-path server-rs/Cargo.toml、cargo test -p module-auth phone --manifest-path server-rs/Cargo.toml、cargo test -p api-server phone_login_reuses_existing_user_for_same_phone_number --manifest-path server-rs/Cargo.toml。
  • 关联:server-rs/crates/module-auth/src/lib.rs、server-rs/crates/spacetime-module/src/auth/procedures.rs、docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md。

SpacetimeDB 持久化 enum 新 variant 只能末尾追加

  • 现象:生产发布时 schema 迁移失败,或旧数据中的 enum 判别序号被新代码解释成其它业务枚举值。
  • 原因:SpacetimeDB schema 会保存 enum variant 顺序;在已有持久化 enum 中间插入新 variant,会让后续 variant 的判别序号整体移动。即使 Rust 代码能编译,发布到已有数据库也可能炸。
  • 处理:给已发布并持久化的 enum 增加 variant 时,只能追加到 enum 末尾;同步运行 npm run spacetime:generate 刷新 bindings,不能手工把 generated bindings 改成另一套顺序。需要调整既有 variant 顺序、删除或重命名时,必须先确认数据迁移方案。
  • 验证:npm run check:spacetime-schema 应通过;对照本次修改前后的 enum,所有旧 variant 顺序必须完全不变,新 variant 只出现在末尾。
  • 关联:server-rs/crates/module-runtime/src/domain.rs、server-rs/crates/spacetime-client/src/module_bindings/、docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md。

SpacetimeDB publish 报 wasm-bindgen 时先查 WASM 依赖链

  • 现象与原因:Rust 编译完成后 publish 报 wasm-bindgen detected。数据库模块不能带入面向 Web 的 HTTP client;常见链路是 spacetime-module -> module-* -> shared-contracts -> platform-* -> reqwest -> wasm-bindgen。
  • 排查:执行 cargo tree -i wasm-bindgen --manifest-path server-rs/Cargo.toml -p spacetime-module --target wasm32-unknown-unknown 定位反向依赖。
  • 处理:共享契约的 OSS 资产契约由 oss-contracts feature 隔离,workspace 依赖保持 default-features = false,需要资产 DTO 的 api-server 显式启用该 feature。若仍有平台实现类型漏进通用 DTO 或领域模块,应把平台响应转换移回 adapter 层,不能只靠发布参数掩盖依赖污染。
  • 验证与入口:依赖检查应输出 nothing to print;检查 wasm32 的 spacetime-module 及原生 shared-contracts、api-server,再按当前发布流程验证。相关 feature 见各 crate 的 Cargo.toml,边界见当前后端数据契约。

浏览器自动填充手机号带 +86

  • 现象:登录弹窗的手机号被浏览器回填为 +86 1xxxxxxxxxx,点击获取验证码或登录后返回“手机号格式不正确”。
  • 原因:autocomplete="tel" 允许浏览器回填含国家码的完整电话号码,inputMode="numeric" 只提示软键盘布局,不会过滤自动填充;如果把完整号码和纯号码混在一个 phone 字段中,微信 purePhoneNumber 又与 countryCode 分开传递,后端容易在国家码丢失后把境外号码误判为 +86。
  • 处理:手机号字段保留 autocomplete="tel";authService 在请求前把 +86 1xxxxxxxxxx、86 1xxxxxxxxxx 拆为 countryCode=86 + purePhoneNumber=1xxxxxxxxxx。普通认证请求缺少 countryCode 时默认 86,但微信授权必须使用 provider 真实返回的 countryCode + purePhoneNumber,不能默认国家码。module-auth 先校验国家码,再用原纯手机号规则校验 purePhoneNumber 并生成 E.164;数据库仍只保存 E.164。
  • 验证:cargo test -p module-auth --manifest-path server-rs/Cargo.toml、定向 api-server 认证测试和 npm run test -- src/services/authService.test.ts src/components/auth/AuthGate.test.tsx,覆盖省略 / 显式 86、境外国家码、浏览器 +86 自动填充以及微信 provider 国家码路径。
  • 关联:server-rs/crates/module-auth/src/domain.rs、server-rs/crates/module-auth/src/errors.rs、server-rs/crates/api-server/src/phone_auth.rs、server-rs/crates/api-server/src/wechat/auth.rs、src/services/authService.ts、src/components/auth/LoginScreen.tsx。

手机验证码登录成功后又瞬间回到未登录

  • 现象:手机号验证码登录先成功,随后 UI 又闪回“未登录”,登录弹窗可能重新出现。
  • 原因:AuthGate 首次 hydrate 会异步轮换 refresh cookie 并请求 /api/auth/me。如果用户在 hydrate 完成前已经登录,晚到的旧 hydrate 仍可能把刚写入的 user 覆盖成 null。
  • 处理:给 AuthGate 的 hydrate 增加版本号保护;登录成功、退出登录和全局 auth 事件都会推进版本号,旧 hydrate 结果到达后直接丢弃。
  • 验证:npm run test -- src/components/auth/AuthGate.test.tsx,新增用例应覆盖“旧 guest hydrate 不覆盖新登录态”。
  • 关联:src/components/auth/AuthGate.tsx、src/components/auth/AuthGate.test.tsx、docs/technical/AUTH_GATE_LOGIN_RACE_GUARD_FIX_2026-05-09.md。

刷新网页后登录态失效

  • 现象:刷新网页后,用户明明有本地 access token,却回到未登录状态。
  • 原因:AuthGate hydrate 曾先强制调用 refreshStoredAccessToken();当 refresh cookie 临时失效、代理错配或后端返回 401 时,该方法会先清空本地 access token,随后 /api/auth/me 只能恢复成未登录。
  • 处理:refreshStoredAccessToken() 增加 clearOnFailure 选项;AuthGate 在已有本地 access token 时先用 /api/auth/me 确认用户,确认成功后再后台 refresh 续期与写每日登录埋点,后台 refresh 失败不清 token。
  • 追加处理:/api/auth/refresh 只有明确返回 401 / 403 时才代表登录态权威失效,可以清本地 access token 并触发全局 auth 变化;服务器重启、Nginx 502/503/504、浏览器 Failed to fetch 或 refresh 响应契约异常都属于暂时不可用,不能把已有本地 token 清掉,否则重启窗口会把所有打开页面踢成未登录。
  • 契约:/api/auth/refresh 成功响应按共享契约 RefreshSessionResponse { token } 解析;测试 mock 不要额外塞 { ok: true, token } 遮住真实恢复路径。
  • 验证:npm run test -- src/services/apiClient.test.ts src/components/auth/AuthGate.test.tsx -t "explicit refresh opts out|auth gate keeps a valid local token login"。
  • 关联:src/services/apiClient.ts、src/components/auth/AuthGate.tsx、docs/technical/AUTH_RESTORE_AND_RECOMMEND_LOADING_FIX_2026-05-09.md。

展示层后台鉴权失败不能清掉全局登录态

  • 原因:局部展示请求的 401/refresh 失败若清 access token 并广播 auth,会把已登录页面变成未登录;只在外层关闭清 token 而 refresh 内部仍清理也无效。
  • 处理:现役请求层用 authImpact:global|local 区分账号权威动作与后台投影;图片换签、编辑器后台任务、运行配置等调用复用 BACKGROUND_AUTH_REQUEST_OPTIONS 或当前 local options。局部失败只影响当前展示,不能充当全局会话判定;用户主动账号动作保留权威处理。
  • 核验:后台 401/刷新失败不清 token、不广播全局 auth;身份/header 仍按请求真实来源,不能用另一种访客凭据掩盖账号问题。
  • 关联:src/services/apiClient.ts、assetReadUrlService.ts、frontendRuntimeConfigService.ts、image-editor/editorAgentClient.ts。

后台表查询展示 SpacetimeDB 枚举时不要套用 Option 解码

  • 现象:后台“表查询”查看 profile_recharge_order 时,kind 和 status 显示为空数组 [],例如充值订单原始行里 points_60 的类型和状态都不可读。
  • 原因:SpacetimeDB HTTP SQL 对无载荷枚举会返回 SATS 形态 [variant_index, []];后台通用 normalizer 曾把任何 [0, value] 都当作 Option::Some(value) 展开,导致 [0, []] 最终只剩 []。
  • 处理:通用表查询解析应先按表名和列名识别已知业务枚举,再落回 Option / Timestamp 通用展开;例如 profile_recharge_order.kind 映射为 points / membership,profile_recharge_order.status 映射为 pending / paid / failed / closed / refunded / expired。
  • 验证:执行 cargo test -p api-server admin_database -- --nocapture,并确认后台详情弹层的 raw 与表格 cells 都显示业务字符串。
  • 关联:server-rs/crates/api-server/src/admin.rs、docs/technical/ADMIN_DATABASE_TABLE_QUERY_2026-05-08.md。

后台通用表查询不能先按每页条数截断再筛选

  • 现象:后台“表查询”填写关键词或 JSON 条件后查不到确定存在的记录;把“条数”从 100 调到 500 只能偶尔缓解,而且页面没有继续翻页的入口。
  • 原因:旧实现先执行 SELECT * FROM <table> LIMIT <limit>,再对这批行做内存过滤;目标记录不在首批结果时永远无法命中,同时响应没有页码、匹配总数或扫描上限状态。
  • 处理:用户输入继续不进入通用 SQL。API Server 通过单次 SELECT * ... LIMIT 50001 读取哨兵行,最多保留前 50,000 条候选,先过滤,再按后端接收的列名 / 方向对完整候选集稳定排序,最后分页;totalMatched、scannedCount 和 scanLimitReached 都从同一份 SQL 结果计算。请求页码超过实际总页数时钳制到末页,零结果固定为第 1 页。响应统一返回 page、totalMatched、scannedCount、scanLimit 和 scanLimitReached,后台翻页栏固定在视口底部;达到扫描上限时明确提示结果可能不完整。32 MiB 是候选 SQL 响应体硬上限,宽表即使每页条数很小也可能整次拒绝,不返回部分结果。实时写入可能改变相邻请求之间的候选快照,精确审计使用专用业务查询。
  • 验证:执行 cargo test -p api-server admin_database -- --nocapture,覆盖第 101 行才命中、完整候选集排序后分页、哨兵截断、越界页码和响应体硬上限;前端测试覆盖下一页沿用已应用条件、后端排序参数 / 返回结果与扫描警告,并运行后台类型检查。
  • 关联:server-rs/crates/api-server/src/admin.rs、server-rs/crates/shared-contracts/src/admin.rs、apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx。

充值订单状态枚举不能用字符串 SQL 字面量订阅

  • 现象:API 已 ready,但日志每 5 秒出现 profile recharge expiration listener failed to subscribe,并提示 pending 不能解析为 profile_recharge_order.status 的枚举类型;scheduled reducer 仍会把订单改成 expired,但微信查单补偿监听没有运行。
  • 原因:SpacetimeDB 2.6 不会把订阅 SQL 中的 'pending' / 'expired' 字符串自动转换为生成绑定的 sum-type enum;两个按状态过滤的订阅都在应用阶段失败。
  • 处理:不要改成订阅完整 profile_recharge_order 历史表。后端订阅只保留活跃五分钟定时器的 profile_recharge_order_expiration_timer,监听 timer 删除后按 order_id 通过 procedure 读取订单,只处理当前状态为 expired 的记录;支付 / 关闭信号会被忽略,断线窗口继续由未检查过期订单 catch-up 补齐。这样既不依赖不受支持的枚举 SQL,也不会把充值历史常驻 API 客户端缓存。
  • 验证:运行 cargo test -p spacetime-client profile_recharge_expiration --manifest-path server-rs/Cargo.toml,发布后确认 API 日志不再出现订阅解析错误,并用真实 pending 订单验证 scheduled reducer 过期后写入 expiration_checked_at。

SpacetimeDB Vec 字段的 default 宏会触发常量求值限制

  • 现象:SpacetimeDB Vec 列使用 #[default(Vec::<String>::new())],WASM 构建报 destructor of Vec<String> cannot be evaluated at compile-time。
  • 原因:table 的 default 宏走编译期常量求值,此处 Vec 默认表达式触发析构检查,不能直接沿用运行时的默认值写法。
  • 处理:user_account.user_tags 使用 Option<Vec<String>> 与 #[default(None::<Vec<String>>)],业务层把 None 归一为空数组;邀请码标签复用 metadata_json.userTags,不再新增独立 Vec 列。旧迁移 JSON 缺字段仍按默认导入;标签公开投影遵守后端数据契约。
  • 关联:server-rs/crates/spacetime-module/src/。

清库重建后先查 schema 兼容再重启

  • 现象:npm run dev -- --clear-database --no-interactive 之后,api-server 仍在 GET /api/creation-entry/config 或订阅恢复阶段报 No such procedure / schema guard 失败。
  • 原因:本地重建只会重发当前 spacetime-module,不会自动修正旧迁移 JSON 的字段兼容;如果 migration.rs 没把新字段补成 None / 默认值,清库后重建仍会卡在 schema 同步。
  • 处理:先让 server-rs/crates/spacetime-module/src/migration.rs、docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md 和生成绑定对齐,再执行清库重建。
  • 验证:npm run check:spacetime-schema 先通过,再重启 npm run dev -- --clear-database --no-interactive,最后检查 /v1/ping、/healthz 和 GET /api/creation-entry/config。
  • 关联:server-rs/crates/spacetime-module/src/migration.rs、docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md、scripts/dev.mjs。

微信历史孤儿作品不要让新注册账号顶替

  • 现象:清空用户数据或迁移历史数据后,旧作品的 owner_user_id 为空或失效,新注册用户会因为顺序号复用或旧 ID 残留顶替作品归属,导致刚注册就看到别人的草稿或已发布作品。
  • 原因:作品作者解析曾经把缺失作者简单回退到普通登录用户,且微信新用户用户名 / 内部 ID 都太容易被误认或复用。
  • 处理:作品作者找不到真实账号时统一回退到占位作者 wx-openid-placeholder,展示名固定为 失效作者;微信新用户用户名改为 名字_openid,内部 user_id 改成不可复用的 UUID 风格;离线回填时先识别真实有效用户,再把孤儿作品表写回占位账号。
  • 验证:cargo test -p module-auth --manifest-path server-rs/Cargo.toml、cargo test -p api-server --manifest-path server-rs/Cargo.toml work_author、npm run test -- scripts/rebind-orphan-work-owners.test.ts。
  • 关联:server-rs/crates/api-server/src/work_author.rs、server-rs/crates/module-auth/src/domain.rs、scripts/rebind-orphan-work-owners.mjs。

Pingora 静态路径必须按 URL segment 解码

  • 现象:dev 页面里部分像素图标加载失败,浏览器直接打开 /Icons/Admurin%27s%20Pixel%20Items/.../499_Iron_Gear.png 返回 200 text/html,响应体是主站 index.html,但服务器磁盘上真实 PNG 文件存在。
  • 原因:浏览器请求中的空格和英文撇号会变成 %20 / %27;Pingora 静态文件解析如果直接把编码后的 path 当磁盘路径查找,就会错过真实文件,并继续落到 SPA fallback,最终让图片解码看到 HTML。
  • 处理:静态路径按 / 拆分 URL segment 后逐段 percent-decode;解码后拒绝 /、\、NUL、.. 和非法 % 编码,既能读取带空格 / 撇号的真实文件,又不重新打开目录穿越边界。
  • 验证:cargo test -p pingora-gateway --manifest-path server-rs/Cargo.toml 必须覆盖编码空格 / 撇号、%2e%2e、%2f 和非法 %GG;npm run check:pingora-gateway-smoke 必须覆盖编码图标路径返回 image/png,并确认危险编码路径仍返回 404。dev 切换后用浏览器或 curl 直接验证对应图标 URL 的 Content-Type 和 PNG magic bytes。
  • 关联:server-rs/crates/pingora-gateway/src/main.rs、scripts/check-pingora-gateway-smoke.mjs、docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md。

SpacetimeDB 连接池租约必须有 Drop 兜底,acquire 不允许无界自旋

  • 现象:release 上 api-server 周期性出现全量 spacetime_stage="pool_acquire" elapsed_ms=45000 业务超时,/readyz 503(reason=spacetime_unhealthy, stage=pool_acquire),/healthz 仍 200,只有重启能恢复,过若干小时复发。
  • 原因:旧 PooledConnectionLease 只能显式 release_connection 归还;HTTP 请求方在等待 StDB 回包期间断开时 handler future 被取消,permit 自动归还但槽位 in_use 永不复位。后续 acquire 在拿到 permit 后进入无界 loop + yield_now 扫描空闲槽位,泄漏积累到 pool_size 后整池挂死。
  • 处理:租约持有 Arc<SpacetimeConnectionPool> 并实现 Drop 统一复位槽位/归还连接;槽位改 AtomicBool CAS 抢占,删除自旋循环(持有 permit 必然命中空闲槽位)。任何新的"显式归还"资源在 async 取消语义下都要先想 Drop 兜底。该保证只覆盖本地 lease / slot / permit 回收;RPC 已发出后,handler timeout/drop 不会取消或回滚远端 procedure,结果仍须按 unknown 读取权威事实。
  • 验证:cargo test -p spacetime-client --manifest-path server-rs/Cargo.toml --lib(dropped_lease_releases_slot_and_permit、acquire_times_out_at_pool_acquire_when_pool_is_busy)。
  • 关联:server-rs/crates/spacetime-client/src/active.rs、docs/【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md。

后台灰度配置不能从 SpacetimeDB 本地表缓存读取

  • 现象:后台灰度页保存 image-editor:agent-sidebar 后当前响应能看到 gate,但刷新后台页列表变空;前台画布 Agent 入口仍显示,0% 灰度没有生效。
  • 原因:feature_gate_config 是后台私有事实表,spacetime-client 如果优先读 SDK 本地订阅表缓存,可能得到空表并覆盖 procedure 返回后的正确缓存。灰度语义里“未配置 gate”表示不限制访问,所以空列表会让功能继续开放。
  • 处理:灰度配置读取必须走 get_feature_gate_config procedure 的事务快照,成功后再更新进程缓存;缓存只作为 procedure 暂时失败后的兜底。不要订阅或读取 feature_gate_config 本地表来判断后台配置。
  • 验证:RUSTC_WRAPPER= cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml;RUSTC_WRAPPER= cargo test -p api-server --manifest-path server-rs/Cargo.toml frontend_runtime_config_denies_anonymous_agent_sidebar_when_gate_enabled;RUSTC_WRAPPER= cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_agent_api_returns_service_unavailable_when_sidebar_gate_denies_user。
  • 关联:server-rs/crates/spacetime-client/src/runtime.rs、server-rs/crates/spacetime-client/src/lib.rs、server-rs/crates/api-server/src/frontend_runtime_config.rs、server-rs/crates/api-server/src/editor_agent.rs。

SPA 路由白名单不能只按一级目录放行

  • 现象:/not-exist 已返回 404,但 /creation/not-exist、/runtime/not-exist 或 /puzzle/not-exist 仍返回 200 首页,搜索引擎继续判定为 soft 404。
  • 原因:Nginx 或 Pingora 使用 /creation/*、/runtime/* 等宽前缀作为 SPA fallback,前端对未知路径又回到平台首页;只验收根级未知 URL 无法发现该问题。
  • 处理:SPA fallback 必须精确匹配当前真实完整路径,同时允许前端已有的大小写归一和尾部斜杠;最终 catch-all 只提供真实静态文件。浏览器 HTML 导航失败时返回品牌 404.html,但状态码仍为 404;API、探针和非 HTML 请求保持原有 404 响应。路由增删同步三套 Nginx、Pingora、route parity matrix 和路由门禁。
  • 验证:除全部真实 SPA 路径外,至少检查 /not-exist、/creation/not-exist、/runtime/not-exist 和 /puzzle/not-exist 均返回 404;带 Accept: text/html 的未知 Web 路径正文命中品牌页,不带 HTML Accept 的请求不得命中品牌页;维护模式仍保持页面 503 优先语义。
  • 关联:src/routing/appRoutes.tsx、src/routing/appPageRoutes.ts、deploy/nginx/、deploy/container/nginx.conf、server-rs/crates/pingora-gateway/src/main.rs。

SpacetimeDB 历史归档不能按文件名小于 snapshot 就全部删除

  • 原因:commitlog 文件名只表示 segment 起始事务;最新 snapshot 前的最后 segment 可能跨边界,仍用于重放,不能把所有小于 snapshot 的文件都删掉。
  • 处理:每 replica 独立确认未锁定完整 snapshot,保留 max(segment_start <= snapshot) 与所有后续 segment,只删更早 segment 对;旧 snapshot 留最新完整项。基线须来自停库目录或已验证冻结副本,在线逐文件扫描不证明一致性。
  • 边界:history 不能代替包含 control-db/program bytes/snapshot/active segment 的 full baseline。发布验真 catalog/latest 前不删源;pointer/metadata 失败不推进 state。超时先核原 PID lock,禁止并发重跑。
  • 关联:scripts/database-backup-to-oss.mjs、scripts/check-database-backup-to-oss.mjs;备份与恢复合同。

Procedure 事务鉴权必须显式捕获调用者

  • 现象:api-server 的 SpacetimeDB token identity 与 editor_generation_pricing_config.writer_identity 完全一致,服务启动门禁也通过,但手动拆分图集调用 find_editor_asset_group_source_and_return 仍返回“当前 identity 无权调用模型生成运行时服务”。
  • 原因:当前 workspace 锁定 SpacetimeDB 2.7.0,procedure 的事务闭包身份仍不应成为业务鉴权的隐式来源。把 runtime writer 鉴权写成 require_*(tx, tx.sender()) 会让鉴权边界依赖 SDK 细节,升级后也容易回归。
  • 处理:在调用 try_with_tx 前通过 let caller = ctx.sender() 捕获真实调用者,再把 caller 显式传入事务函数;迁移、后台账号、runtime profile、外部生成等现有 procedure 已采用这一模式。npm run check:spacetime-runtime-access 禁止编辑器 runtime writer 鉴权重新直接读取事务 ctx.sender()。
  • 验证:运行 npm run check:spacetime-runtime-access、cargo test -p spacetime-module --manifest-path server-rs/Cargo.toml、npm run check:spacetime-schema;使用当前 2.7.0 模块以 runtime writer identity 重试 POST /api/editor/icon-spritesheets/slices,确认来源查询、分片素材写入与 cohort 完成不再返回 identity 403。
  • 关联:server-rs/crates/spacetime-module/src/editor_project_storage.rs、scripts/check-spacetime-runtime-access.mjs、docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md。

VectorEngine 请求超时不能脱离 worker 绝对预算(2026-07-20)

  • 现象:VectorEngine 单次请求超时大于 worker job 执行预算时,worker 已停止续租,provider 才超时或开始重试;最终 lease 过期、任务失败并退款,上游却可能继续消耗资源或迟到成功。
  • 原因:单 attempt timeout、重试退避、图片下载与 worker / lease 分别使用独立的相对计时,没有共享同一绝对 deadline;只抬高 worker timeout 或单独压低 provider timeout 都无法保证留出终态写回窗口。
  • 处理:实际调用 VectorEngine 的四类图片 job 使用 1800s long 预算;从 job 开始的同一起点派生 provider deadline,常规提前 60s、短预算提前一半。每次 attempt、退避、下一次 attempt 和图片下载都必须在该 deadline 内;普通 HTTP / inline 不伪造 worker deadline。修复时不改动 lease fencing、迟到写回仲裁和原子退款语义。

Rust u64 revision 不能直接穿过 JavaScript number 边界(2026-07-31)

  • 现象:资源布局 sidecar 的 revision 在 Rust 中可增长到完整 u64,但经 JSON / Tauri 返回 TypeScript 后只能用 number 表示;超过 9_007_199_254_740_991 时相邻整数会折叠为同一值,窗口可能持续 conflict,甚至用失真的 expectedRevision 破坏 CAS 判等语义。
  • 原因:Rust 的 checked_add 只防止 u64 溢出,不能证明序列化后的整数仍能被 JavaScript 精确表示;纯 Rust u64::MAX 测试没有经过真实跨 JSON 合同。
  • 处理:保留 Rust u64 存储类型,但把共享合同合法域冻结为 0..=Number.MAX_SAFE_INTEGER。共享 DTO 对 revision 自定义 serde 校验,Tauri 更新在任何项目或锁副作用前验证 expectedRevision,sidecar 读取拒绝超限值,前端在 IPC 读取、更新响应和请求发送前重复验证非负安全整数;达到上限时写入失败且 sidecar 字节不变。
  • 验证:Rust 与 TypeScript 合同测试分别覆盖最大安全值往返、最大值加一拒绝;Tauri 持久层覆盖超限 expectedRevision 零 workbench 副作用、超限 sidecar 原字节保留和最大安全值递增失败;Hook 覆盖不可信读写响应不能进入 CAS。
  • 关联:server-rs/crates/shared-contracts/src/game_creation_app.rs、packages/shared/src/contracts/gameCreationApp.ts、apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs、apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts。

异步生成结果未知时不能换幂等键重提(2026-07-31)

  • 原因:提交超时、断连或响应丢失不证明服务端未受理;换 Idempotency-Key 重提会重复生成、扣费和写回。MCP 绕过 External REST 路由 也会形成第二套去重语义。
  • 处理:逻辑请求持久保留同一幂等键、冻结正文与 operationId。accepted 恢复直接使用 durable snapshot 查询 operation,不能在读账本前重做项目/素材目录准备、输出路径预检或正文构造。清理最后删除 pending 身份锚点,活动 orphan 不自动删;恢复 future 不靠调大线程栈掩盖溢出。
  • 边界:snapshot 绑定无明文凭据的规范服务身份指纹;地址漂移阻断 POST 和 GET,API Key 轮换继续原 operation。失败 observation 未持久化不得删账本。只有首次提交明确 400/401/403 才证明未入队;首次结果未知后,恢复临时鉴权、限流、冲突及传输错误都保留原账本。安全结果白名单落盘,扫描/删除逐级拒绝链接。
  • 代理例外:透明代理可能将 OSS 域名解析到 198.18.0.0/15 fake-IP。仅鉴权 objectKey/受控 历史路径 换签 URL 且全部地址位于该段时接受;直接 URL、其它私网、公私混合和重定向仍拒绝,不能整体移除 SSRF 校验。
  • 关联:api-server/src/external_generation.rs、api-server/src/external_mcp.rs;External异步与幂等合同、AGC图片恢复合同。

MCP 列表不能透传完整项目快照(2026-08-07)

  • 现象:账号项目数量增长后,list_editor_projects 把每个项目的 canvas / layers / resources 全量透传,REST 响应超过 MCP 4 MiB 上限,Agent 因整批失败而无法展示、查重或安全选择项目;缺少必填请求体时,内部 Axum JSON extractor 的文本 415 又会被泛化成“非 JSON 响应”。
  • 处理:项目列表 REST 保持默认 view=full 兼容,并提供 view=summary;MCP 固定使用 summary 且不向 Agent 暴露或接受 view=full。摘要只返回 projectId / title / updatedAt / cover,封面取最新且存在稳定 objectKey 的 project-cover-snapshot,展示时再调用 /assets/read-url,不在列表内嵌图片或签名 URL。MCP 在构造内部 REST 请求前按 OpenAPI schema 校验 required body;缺正文和缺字段分别返回结构化错误,不进入写入、上传票据或计费路径。
  • 验证:用 19 个完整序列化后超过 4 MiB 的项目 fixture 证明摘要仍低于上限且不含大型布局;覆盖四个历史 415 工具的缺正文、空对象和非对象输入,并断言项目列表工具固定 summary、调用方不能通过 query 覆盖。
  • 关联:server-rs/crates/api-server/src/external_mcp.rs、server-rs/crates/api-server/src/external_editor_api.rs、docs/openapi/genarrative-external-v1.openapi.json、docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md。

账号级轮询和并发 bootstrap 必须中止整条旧生命周期(2026-08-06)

  • 现象:任务列表 Promise.all 一侧失败后,另一侧请求可能跨过重试和卸载继续悬挂;微信充值第一次确认返回 pending 后切换账号,旧订单的延迟重试可能使用新账号 Token 再次请求,401 路径还会影响新账号登录态。
  • 原因:只用 React state 或最终回调里的 owner 判断,无法阻止已安排的 timer、下一次 HTTP 请求和同轮未完成分支继续执行;每轮重试覆盖单个 controller ref,也会遗失更早的悬挂请求。
  • 处理:并发 bootstrap 每次 attempt 使用独立 AbortController,任一分支失败时先中止同轮 controller 再安排有界重试,卸载时中止当前 attempt。充值订单从创建成功起持有同一个 owner、账号 revision 和 AbortController;每次 delay、confirm 和 SSE watch 前后都校验生命周期,并把同一 signal 传到请求层;账号切换和卸载先 abort,再清理 ref、state 与旧支付回调 hash。
  • 验证:bootstrap 用例覆盖“一侧 reject、另一侧 pending、重试后卸载”,并断言每轮 signal 都已中止;充值用 fake timer 证明首次确认 pending 后切换账号会中止 signal,推进全部退避时间也不会产生第二个确认请求或清理新账号 Token。

中止 refresh 等待不等于隔离 token 发布(2026-08-07)

  • 现象:A 账号的写请求 401 后开始共享 refresh,随后切换到 B。A 的 AbortSignal 虽然让业务请求立即结束且不再重放 POST,但底层 refresh 为了其它共享等待者不会被中止;A 的成功回包晚到时仍可能覆盖 B 的 token。
  • 原因:只对 await 叠加 abort 保护了调用链,没有给共享 Promise 的归属和最终 token 写入加账号栅栏;单一全局 Promise 还会让 B 加入 A 已在途的 refresh。
  • 处理:公开 token setter / clearer 每次都推进 auth generation;refresh 按 generation + 发起时 access token 共享、并以该快照 CAS 发布成功 token。快照已过期时成功回包转为失效结果,401/403 也不得清理新代际 token;新代际建立自己的 refresh Promise,旧 Promise 收尾时不得清掉新尝试。
  • 验证:src/services/apiClient.test.ts 要等旧 refresh 完整收束后断言 B token 不变,并用两个独立 deferred response 证明 B 会发起第二个 /api/auth/refresh;另覆盖旧 refresh 401 晚到不清 B token。

托管 MCP 新增公开域名时不能只更新网关路由(2026-08-05)

  • 现象:https://dev.genarrative.world/api/external/v1/mcp 的 manifest、OpenAPI 和 Bearer 鉴权都正常,但鉴权后的 initialize 返回 403 FORBIDDEN;通过 SSH 隧道访问同一 api-server 的 loopback 地址却可以正常列出 tools/resources。
  • 原因:rmcp Streamable HTTP transport 自带 DNS rebinding 防护。公网网关已经接入 dev 域名,但 external_mcp::service() 的 allowed_hosts / allowed_origins 仍只登记正式域名和 localhost,因此请求在 MCP 协议处理前被 transport 拒绝。
  • 处理:新增公开 MCP 环境时,同批登记对应 Host 与 HTTPS Origin;不要通过客户端伪造 Host、关闭防护或改走内部 SpacetimeDB MCP 规避。allowlist 变更属于 api-server 发布内容,必须随正常 API release 部署到目标环境。
  • 验证:自动测试使用真实公开 Host/Origin 执行 initialize;部署后再从公网域名完成带 Key 的 initialize、tools/list、resources/list、Skill resource 读取和至少一个只读业务 tool 调用。loopback 成功只能证明 MCP 实现和 Key 可用,不能替代公网 Host 验收。
  • 关联:server-rs/crates/api-server/src/external_mcp.rs、docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md。

客户端内部用途目录不能直接作为 legacyPrefix(2026-08-10)

  • 现象:本地图片精修或视频、音频等全类型资源编辑点击生成后立即提示参考资源上传失败;私有账本的 uploadBucket/uploadObjectKey/operationId/requestBodyJson 全为空,服务端也没有 OSS、confirm、生成或扣费记录。
  • 原因:direct-upload ticket 的 legacyPrefix 不是任意业务目录,而是 platform-oss::LegacyAssetPrefix 的权威白名单值。把 asset-canvas-references 或 resource-editor-references 直接放在该字段会被 api-server 在签名之前以 400 拒绝;客户端若把票据、OSS 和 confirm 全折叠成一个错误码,还会掩盖真正失败阶段。
  • 处理:客户端编辑器统一使用合法私有 legacyPrefix=generated-character-drafts,把业务用途放入 pathSegments:全类型资源编辑为 editor/resource-editor-references/<projectId>/<operationId>。仍严格执行 ticket → OSS form POST → object confirm,只有 confirm 返回自洽稳定 objectKey/assetObjectId 后才允许提交生成;不要为内部目录扩白名单或新建上传接口。图片路径按本地校验、票据、对象上传、对象确认分别使用安全错误码,票据材料继续只驻留内存。
  • 验证:客户端端到端测试必须断言 confirm 早于生成 POST、请求使用精确前缀与 pathSegments、账本只持久化稳定对象身份;票据失败时断言 operationId/requestBodyJson 为空且 manifest 只有源资产。api-server 测试应断言生成的 key 位于 generated-character-drafts/editor/... 且 access 为 private。
  • 关联:apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs、server-rs/crates/api-server/src/assets.rs。

编辑器生成不能把传输重试、参考图截断和客户端 provenance 当成独立小问题(2026-08-05)

  • 原因:客户端复用 x-request-id,但队列按随机 job ID 去重,响应丢失重试仍产生第二任务;.take(...) 静默截断让 UI 所见参考图未送 provider;客户端 generationInputs.references 被误当权威来源。
  • 处理:生成 POST 禁止自动重试,显式稳定 request ID 接入队列唯一键并校验重放 payload;所有边界显式拒绝超限,不静默 take。入队、完美像素及直接创建资源/素材时删除客户端 references,执行时按真实参考源与 owner 记录重建。
  • 兼容:历史任务比较接受仅差已删除 references 的旧 payload,不能只保留旧 hash 却让 payload 比较冲突。上传预留、面板归属及部分成功项保留按现役上传合同处理。
  • 关联:server-rs/crates/api-server/src/editor_generation_queue.rs、server-rs/crates/spacetime-module/src/external_generation.rs;编辑器生成契约。

生成结果的稳定 ID 和 job 终态都不能代替 durable receipt(2026-08-06)

  • 原因:稳定的 resource ID 或 job 的 completed 状态,只能证明局部记录存在,不能证明对象、资源、素材、绑定、画布和任务已整笔提交;请求指纹也不绑定最终记录内容。
  • 处理:editor_generation_operation 分别保存请求指纹与整笔 commit SHA-256。同一 SpacetimeDB 事务校验 lease、owner/project/source,写入全部结果和 receipt。重放先查 receipt,再按 slot 回读权威 tuple;没有 receipt 的部分记录不得补票,已有 object 也须逐字段验真。已有 job 却漏写 completion 时必须回滚。
  • 未知结果:procedure 超时或断连可能发生在提交之后,只能有界重放同一 prepared commit。计费补偿在真正 dispatch 前同步标记 unknown:Build、Pool、Connect 等尚未发出请求的失败可退款,dispatch 后结果未知则不退款,等待 receipt 对账。queue 失败与退款须在同一事务校验 lease;inline billing guard 的成功完成边界延迟到 durable commit,此前费用已预扣,明确失败退款,未知结果保留扣款。
  • 边界:冻结候选时间并纳入提交指纹,重放不刷新;画布 CAS 后只刷新布局,不重跑 Provider/OSS,也不回拨 updated_at。OSS 不在事务内,无引用 object 仍需清理,不能承诺跨 OSS 的 exactly-once。
  • 关联:生成结果原子提交与幂等重放方案。

付费生成不能把素材目录归属校验留到 provider 之后(2026-08-07)

  • 现象:登录用户给自己的合法 projectId 搭配不存在或属于其他账号的 assetFolderId,图片、改图、图集、UI 提取、视频、角色动作或音频生成会先扣泥点并调用付费 provider / OSS,直到创建 editor_asset 才拒绝目录;失败退款让用户成本归零,平台侧 provider 和存储成本不可逆。
  • 原因:normalize_generated_asset_folder_id 只处理 project、旧 folder-* 和默认目录 ID 的兼容映射,不读取 SpacetimeDB;真正的 require_owned_asset_folder 位于生成结果持久化末端。把“失败会退款”误当成副作用补偿,漏掉退款不能撤销 provider 请求与 OSS PUT。
  • 处理:所有付费编辑器生成在队列 enqueue 前和 worker / inline 执行前复用只读 preflight_editor_generation_target_and_return,按认证 owner 校验可选项目及归一化目录;读取失败和归属不匹配一律失败关闭。helper 返回 canonical 项目与目录并覆写后续入队 / worker / 原子准备使用的 payload,不能校验 trim 后的项目却持久化原始空白值。角色图片、角色动作、图标 spritesheet 与 UI 提取省略目录时按实际默认目录预检;默认目录允许尚未创建,自定义目录必须存在且 owned。预检不替代最终 procedure 复验,也不保证跨外部调用的目录锁定。
  • 验证:源码顺序回归必须覆盖图片生成、图片修改、图标 spritesheet、UI 设计图提取、视频、角色动作、SFX 与 BGM 的 enqueue / direct 两层,证明纯本地格式和 data: / blob: 稳定引用门禁先执行,canonical target 在预检后写回 payload,远端引用解析、generation input rebuild、扣费、入队、provider 与 OSS 均留在预检之后;模块侧扫描证明预检只调用 runtime identity、项目、目录只读校验且不含 insert / update / delete,并覆盖带空白项目、project、旧 folder-*、默认目录 ID、自定义目录与 None 归一化。

图片编辑请求与资源卡几何不能依赖宽松 DTO 或可淘汰预览(2026-08-20)

  • 现象一:Game Agent 快速编辑返回“平台明确拒绝”,远端没有创建任务。原因是 /api/external/v1/editor/images/edits 使用 deny_unknown_fields,客户端却把创建接口的 assetKind 以及本地来源字段一起发送;统一 400 文案又掩盖了真实参数错误。处理时必须按 create/edit 各自权威 DTO 组装请求,Game Agent kind 先映射为编辑器 canonical kind,并把安全业务错误分类,不能回显远端敏感 body。
  • 现象二:图片预览已经读取真实宽高,但大量资源触发 LRU 淘汰后,卡片又退回 180x128,布局和连线随之跳动。原因是布局尺寸直接从可淘汰的 Blob/Object URL 预览缓存派生。处理时预览二进制仍按预算淘汰,但已验证的轻量 pixelWidth/pixelHeight 必须在当前项目、模式和资源 identity 作用域内独立保留;identity 或 scope 变化时再清理。
  • 验证:分别覆盖严格 edit body、错误分类、生成占位恢复,以及超过预览缓存条目上限后首张图片仍保持真实比例、布局碰撞和依赖端点不退化。

AGC 登录态续期必须同步本地运行时

  • 模型目录 HTTP 请求与 DirectProject 的 Rust/app-server 使用同一账号,但凭据分别保存在 WebView 与 Rust / Runner;续期应复用 requestPlatformSessionRefresh 完成用户核验及本地会话安装,不能只写 localStorage。
  • 普通 API 仅在 401 时续期并至多重试一次;403 权限拒绝不重发。对话续期失败保留原机器可读鉴权错误,避免用户提示退化为普通执行失败;账号代次变化时停止旧请求。

2026-09-16 并发生图遇到“登录态冲突”:身份代次与凭据轮换混用

  • 现象:DirectProject 长回合里并发派发的生图 / 素材生成请求中途报 authentication-required: 陶泥儿登录态已变化,旧账号请求已停止,请使用当前账号重试,或平台工具返回 HTTP 401 invalid-token;账号并没有切换,重新登录后短时间内可复现。
  • 原因:客户端把两件事压成了一个判据。原生冻结会话(PlatformSessionSnapshot)同时承担“身份归属”和“access token 字节比对”,而长回合保活与 401 续期每次都会签发新 token 并推进 native generation 重新安装会话;于是同账号的正常续期被等价成换号,续期窗口内所有在途生成、编辑、上传、确认和下载 operation 全部失配。保活定时器(回合 busy 时每 5 分钟一次)会稳定落在生图窗口内,所以并发越多越必现。另一层在服务端:/api/auth/refresh 是严格一次性轮换,且失败时下发清空 refresh cookie 的响应;两个窗口 / 实例并发续期时,输的一方会把赢家刚写入的有效 cookie 删掉。
  • 处理(现行口径):平台会话统一拆成身份(userId + api origin + identity generation)与凭据(当前 access token)。identity generation 只在登录、切号、登出和新的 GUI authority epoch 推进;同账号续期只更新凭据并推进写入 revision(revision 只用于拒绝迟到写入)。冻结会话校验只比身份,比 token 字节的判据已被取代。刷新失败语义收紧为:只有服务端明确返回 401/403 且经一次收敛重试后仍失败,才清本地会话;网络错误、5xx、网关错误和响应契约异常必须保留会话与 access token。/api/auth/refresh 的轮换失败不再下发清 cookie 响应。
  • 验证:AGC platform_session::tests 覆盖“同身份 token 轮换后冻结会话仍有效”“换号 / 退出后失效”“迟到 install 被 revision 拒绝”;appSurface 前端用例覆盖“续期不推进身份代次且 native 写入 revision 递增”“瞬时刷新失败不清会话”;网站 src/services/apiClient.test.ts 覆盖“刷新 401 后收敛重试成功”与“两次都被拒绝才判权威失效”;api-server refresh_session_* 覆盖“轮换失败不下发清 cookie”。定位同类问题先看冻结会话判据里有没有 token 字节,再看服务端失败响应有没有 Max-Age=0。

2026-09-28 game-distribution 发行包对象在 dev bucket 可匿名直取(platform-oss 不发对象级 ACL)

  • 原因:接收 OssObjectAccess 并写日志不证明 PUT 发出 ACL;未发送 x-oss-object-acl 的“private”对象仍继承 bucket 默认值,公开 bucket 上发行包、项目快照及后台导出可被匿名直取。
  • 处理:platform-oss 内部 PUT、分片追加与直传 policy/表单均显式下发对象级 ACL;直传 policy 约束同值。生产 bucket 保持私有,匿名直取应被拒绝;使用 E2E_REQUIRE_PRIVATE_BUCKET=1 检查,默认 WARN 不能当隐私验收。
  • 存量:修复上传实现不改变旧对象 ACL,仍需按需回填或换键。
  • 关联:server-rs/crates/platform-oss/src/lib.rs、scripts/check-game-distribution-media-e2e.mjs。

Responses 历史文本必须按 role 使用正确内容类型

  • 现象:把 assistant 历史与 system/user 一样序列化为 input_text,会使 Responses 请求返回 400。
  • 处理:system/user 文本用 input_text,assistant 文本用 output_text;不能把不支持的 assistant 图片当 input_image 发出。回归应覆盖实际请求体,而非只检查解析后的回复。
  • 关联:server-rs/crates/platform-llm/src/provider_adapter.rs、agent/direct_project_history.rs。

Agent 与 Runtime

AGC 批量读取参数失败

agc_read_project_context 的 files 必须是对象数组,例如 {"files":[{"path":"game/game.js"}]}。空对象缺少必填字段,路径字符串数组也不符合契约;这两类错误发生在文件读取之前。startLine / maxLines 可独立省略,默认 1 / 160。排障先核对实际调用参数;解析错误保留 Serde 的实际原因并附中文结构说明与示例,不用数组中任意字符串的位置推断本次失败原因,否则后项会覆盖前项缺 path 或类型错误的诊断。

2026-10-08 api-server 不再做 Anthropic↔Chat 协议转换

  • 背景:/api/llm/anthropic/* 原来是"原生直通 + 失败回退协议桥接":非 2xx 或 20 秒首包超时就自己把 Anthropic Messages 转成 Chat Completions,再把回程 SSE 翻回 Anthropic 事件(anthropic_bridge.rs)。实测这类双重转换会丢信息(thinking、cache_control、工具 schema、图片块),而且 Router 日志里同一个模型会多出一种协议(/v1/chat/completions)。
  • 现行口径:这条路由只做原样转发——把请求体(只改写 model)直接 POST 到 Router 的 /v1/messages,状态码、响应头与正文/流一律透传;anthropic_bridge.rs 与 LLM_ANTHROPIC_NATIVE_FIRST_BYTE_TIMEOUT 已删除。账号所在分组没有 Anthropic 渠道时就把 Router 的原始错误(not implemented / model_not_found)交给调用方,由渠道配置解决,不在中间层改写协议。流式计费仍在 message_stop/流结束时结算(stream_messages_with_billing)。
  • 验证:cargo test -p api-server --bin api-server llm:: 28 passed,含重写后的 llm_anthropic_messages_forwards_native_messages_to_the_router(断言上游是 POST /v1/messages、响应正文逐字透传)与 llm_anthropic_messages_forwards_native_stream_unchanged(断言 Anthropic SSE 原样回传,且没有 [DONE]/chatcmpl 痕迹)。
  • 关联:server-rs/crates/api-server/src/llm/mod.rs(删除了 llm/anthropic_bridge.rs)。

2026-10-08 cc 模式下同一模型出现三种协议:润色/自动命名没跟执行器走

  • 现象:Router 日志里同一个 claude-opus-5-5 同时出现 /v1/messages、/v1/chat/completions、/v1/responses 三条;其中 /v1/responses(原生格式)被路由到只支持 Claude Messages 的 opus5.5(cc) 渠道,直接 status_code=500, not implemented。
  • 根因:前两条当时来自 cc 主回合的两跳(原生直通 + 已删除的桥接回退);第三条来自聊天输入区 AI 润色 / 自动项目命名——commands/desktop.rs 只在 agent_mode == codex_app_server 时走 direct_game_creator_home_codex_chat,cc 模式落进 else 分支用配置里的 llm.api_kind(openai_responses)直连平台,于是 cc 模型被用 Responses 协议调了一次,而它的渠道只认 Messages。direct_game_creator_home_codex_chat 本身已经会按 agent_mode 分流到 cc,所以只是调用方判据漏了 cc。
  • 现行口径:short_text_uses_agent_executor(agent_mode)(commands.rs)把 codex_app_server 与 claude_code_cli 一并算作"走 Agent 执行器",润色与自动命名都改用它判定;cc 模式下这两条调用与主回合共用同一条 cc 通道(Anthropic Messages),不再产生 Responses 请求。
  • 验证:cargo test -p genarrative-ai-game-creator-shell --bin genarrative-ai-game-creator-shell -- short_text_helpers_follow_the_agent_executor_in_cc_mode 通过(断言 cc/codex 走执行器、codex_cli/provider 不走);真实验收需新构建后看 Router 日志里该模型只剩 /v1/messages(协议桥接已删除,见本文件"api-server 不再做 Anthropic↔Chat 协议转换"条)。
  • 关联:apps/ai-game-creator-shell/src-tauri/src/commands.rs、commands/desktop.rs、agent/codex_app_server/mod.rs(direct_game_creator_home_codex_chat 的 cc 分流)。

2026-10-08 cc 回合的上游错误被显示两次

  • 现象:回合以 API Error: 400 上游服务暂时不可用… 失败时,面板里同一句话出现两条:一条是裸的 API Error: … 过程行,另一条是"陶泥儿智能创作 执行通道中断…:Claude Code 返回失败终态:API Error: …"。
  • 根因:CLI 在终态失败时把同一句上游错误既作为 assistant 文本事件、又作为结果错误发出;assistant 文本那条会被投影成一条可见回复(并落进过程历史),再叠上回合失败文案就是重复的"中断提示"。
  • 现行口径:handle_assistant 丢弃以 API Error: 开头的文本块(claude_text_is_terminal_api_error),上游错误只保留回合失败文案里那一份;模型正常输出不受影响。
  • 验证:cargo test -p genarrative-ai-game-creator-shell --bin genarrative-ai-game-creator-shell -- claude_ 38 passed,其中 claude_api_error_retry_item_surfaces_status_and_upstream_body 断言终态错误文本不进入 assistant_texts、正常文本照常投影。
  • 关联:apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs。

2026-10-08 cc 的 MCP 工具"必定先失败一次":合同 schema 缺 description、读上下文拒空参数

  • 现象:安装版 0.1.234 上 opus 每轮开局都会留下失败的工具调用:agc_read_project_context 先被用 {} 调一次(错误"批量读取参数只接受 files,以及 path/startLine/maxLines"),agc_register_delivery_contract 连试 7 次、换着 changeKind 猜,全部 delivery-contract-invalid,界面显示"执行了 N 个操作,有操作失败"。
  • 根因 1(合同):工具 schema 的 requirements 项只有 kind/id/scenario,但模型习惯每条再带一句 description;Criterion 用 deny_unknown_fields,多出来的字段让整份合同解析失败,而错误文案只说"只接受 scope、changeKind 和 visual/gameplay requirements",模型只能继续猜。现行口径:Criterion 接受可选 description(skip_serializing,不写进 frozen contract),合同解析失败时把 serde 原文带进错误(unknown variant new-game`` 这类),模型下一轮能直接改对。
  • 根因 2(读上下文):agc_read_project_context 是批量读取(files 必填),但 opus 会先来一次 {} 探路;实现直接回错误。现行口径:files 缺省/空数组时按默认上下文处理——读 AGENTS.md/README.md/package.json/game/* 等入口文件(与宿主回合预取的候选清单共用一份常量),项目里一个都没有时回一条带 hint 的成功结果。
  • 验证:cargo test -p genarrative-ai-game-creator-shell --bin genarrative-ai-game-creator-shell -- direct_project_context direct_delivery 23 passed,含:空参数读取返回默认上下文且 files 非空、带 description 的合同被接受且不落盘、非法 changeKind 与未知字段的错误里带原文。
  • 关联:apps/ai-game-creator-shell/src-tauri/src/agent/direct_delivery.rs、agent/direct_project_context.rs、agent/direct_tools_mcp.rs(工具 schema)。

2026-10-07 fnm/nvm 托管的 Node 进 bwrap:单文件挂载 npm 必失败,--tmpfs /run 会抹掉 multishell PATH

  • 现象:开发构建在 Linux 命令沙箱里执行 npm run build / npm install 时,宿主 shell 里明明能跑通的 fnm Node,进沙箱后报 Node.js v26.10.0 与 Cannot find module '../lib/cli.js',或 npm 命令在路径解析阶段就失败。
  • 根因 1(单文件挂载软链前缀):fnm / nvm 的 <前缀>/bin/npm 是指向 <前缀>/lib/node_modules/npm/bin/npm-cli.js 的软链。bwrap --ro-bind <前缀>/bin/npm <前缀>/bin/npm 只挂载这一个文件,npm-cli.js 里的 require('../lib/cli.js') 找不到同安装内的相对目标,于是报错;必须整棵只读挂载通过窄叶校验的完整安装前缀(含 bin/node、lib/node_modules/npm),不能只挂 shim 或 bin/。
  • 根因 2(--tmpfs /run 抹掉活动版本):fnm 的活动 PATH 项是 /run/user/<uid>/fnm_multishells/<pid>/bin;sandbox 的 --tmpfs /run 会清空该目录,sandbox 内解析到的 node 随之失效或退回系统版本。不要依赖宿主 PATH 原样进入沙箱:canonicalize 路径,并把活动版本管理器变量(如 FNM_MULTISHELL_PATH)从 sandbox 环境里剔除。
  • 根因 3(错误取舍会打穿测试):把宿主 node / npm / npx shim 指到 /usr/bin/* 能让沙箱借用系统 Node,但在本机系统 Node 26 上会默认启用实验性 Web Storage,顶掉 vitest 0.34 jsdom 的 localStorage,AGC 测试在 HEAD 即红(见「AGC 测试必须进入类型门禁,并使用项目 Node 环境」条的环境提示)。正确方向是原生支持托管安装,而不是改宿主 shim。
  • 补充(nvm default 别名是主版本号):nvm alias default 22 写进 $NVM_DIR/alias/default 的内容是 22,不是完整三元组。若按精确 (22,0,0) 去匹配 versions/node/v22.23.3 会永远落空,默认别名形同不存在;必须用与 .nvmrc 相同的比较子集在已安装版本里选最高匹配。
  • 根因 4(联网放行只看 basename 会被同名文件冒充):npm install 是唯一允许联网的入口,最初只按「首个参数 basename 是 npm-cli.js 且第二个参数是 install」放行。Linux 上 command.exec 不对 node 的脚本参数做路径校验,项目根又可写,于是在项目里放一个自写的 npm-cli.js 再 node 项目/npm-cli.js install 就能拿到 --share-net。修法是让联网放行与解析层给出的可信 (node, npm_cli) 精确比对,拿不到可信对时一律不放网;只加「路径必须绝对」不够,绝对路径的项目内文件照样绕过。
  • 现行口径:宿主发现与沙箱挂载共用 validate_node_installation_prefix;.nvmrc / .node-version(nvm / fnm 都读)命中已安装版本时优先、未安装时继续回退,engines.node 同为偏好;Linux npm 以 node <npm-cli.js> 启动,npm install 联网放行只认解析层给出的可信 (node, npm_cli) 精确匹配。契约见技术方案 V1.11.2。
  • 验证:GENARRATIVE_COMMAND_SANDBOX_REAL_TEST=1 跑 command_sandbox_real_linux_opt_in_runs_host_node_and_npm_cli,在真实 fnm v22 前缀下 bwrap 内 node --version 与 node <npm-cli.js> --version 均通过;单元用例覆盖 pin / engines / 不支持写法 / 宽叶与逃逸前缀 / 可信 launcher 联网放行。

2026-10-04 把非素材任务塞进「生成任务」账本:taskType 维度、v1 兼容与 live 集合

  • 原因:账本把“非终态但不在本进程 live 集合”的记录视为上次运行残留;前端驱动命名任务若只写账本不登记 live,读取列表就会误收口失败。
  • 处理:v2 账本用 taskType 区分素材与命名,kind 可空;新构建兼容 v1 素材记录。命名 enqueue 写盘前登记 live,写盘失败只回滚本次登记;update 非终态幂等登记,终态持久成功后摘除。同项目与 project.rename 权限必须核验;终态仅接受同状态重放。
  • 边界:账本只是展示旁路,失败不阻断建项/命名/首轮创作。前端先订阅再读快照,存在在途命名时打开面板补读。旧构建不能解析带 kind:null 的 v2 命名行,降级/混跑仍有整账本读失败风险,不能宣称双向兼容。
  • 关联:asset_generation_tasks.rs、src/features/app-shell/useHomeProjectCreation.ts、src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts。

2026-10-02 固定试玩误判祖先的指针穿透样式

  • pointer-events:none 不会强制禁用整棵子树;后代显式 auto 可以恢复命中。控件探针只检查目标的计算样式,继承未覆盖的 none 仍拒绝;可见性、遮挡、disabled 与 inert 保留各自检查。
  • 修复和回归必须经过生产输入入口及可信事件驱动的状态变化,不能用程序化点击证明真实可玩。双视口 generic 回归与真实触摸验收需要区分,详见 AGC 实施计划“固定试玩控件的指针命中边界”。

2026-09-29 逐 delta 脱敏会吃掉段尾换行:DirectProject 汇报的 Markdown 表格整块失效

  • 现象:逐 delta 清洗裁掉末尾换行时,段落、列表和 Markdown 表格被拼成一行;把片段首个 / 当绝对路径起点还可能切坏安全相对路径。
  • 原因:孤立片段缺少前文边界,lines().join() 不保留末尾换行;错误占位符变长后也可能被“取更长正文”的合并规则保留。
  • 当前:Rust ThreadManager 原样透传,流式与历史显示统一经前端 directThreadSanitize。修改清洗器应保留换行,并用不同切段方式验证完整文本与流式投影,不把旧 Rust 逐 delta 实现当当前入口。
  • 关联:src/view/project-development/chat/conversation/directThreadSanitize.ts、directThreadChat.ts。

2026-10-01 文件列举被无关临时锁删除打断

  • read_dir 返回名字后,文件可能在 symlink_metadata 前正常消失;.agent/project.lock 的正常释放就能触发这类竞态。不要先全项目扫描再按 Agent 的 path 和可见性过滤。
  • 项目内列举在元数据读取前裁剪范围和受保护路径,只进入目标子树及必要祖先;共享文件树保留自己的可见性策略。枚举后消失的文件/目录仅跳过 NotFound,其它 IO 错误仍可诊断,进入排队目录前复核链接/重解析点。
  • 并发回归用通道协调真实写锁的释放与元数据读取,不靠高频循环碰撞;分页按本次观察到的可见文件排序,不保证跨请求快照。完整合同见 AGC 实施计划“项目文件列举的范围与并发边界”。

2026-09-29 Game Agent 读工具被项目相对路径规则拦住

  • 项目外读取不能只依赖末段 O_NOFOLLOW:父目录符号链接可隐藏 .ssh 等受保护名字。文件读取和目录列表在访问前逐段检查原始路径,拒绝符号链接与 Windows 重解析点;目录扫描对子目录再次检查。系统临时目录若含平台别名(例如 macOS /var),普通读取测试使用临时目录的 canonical 路径,不能通过 canonicalize 待读路径来抹掉待检测链接。
  • 现象:agc_read_project_context 或 agc_list_project_files 对绝对路径返回「项目文件路径不能是绝对路径」,对 .. 返回「项目文件路径非法」。
  • 原因:这两条读取入口以前直接调用共享 normalize_relative_path。该函数同时服务项目浏览、快照和写入,不能放宽。
  • 处理:读取入口改走 resolve_game_agent_read_path。绝对路径和离开项目的 .. 可以读;项目内的 .agent、凭据文件名、符号链接和硬链接仍然拒绝。写入、补丁、素材导入和 read-only 沙箱不变。
  • 关联:apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs。

2026-09-29 Codex 隔离用户目录被系统临时目录的符号链接拦下

  • 现象:Game Agent 回合还没改项目文件就失败。卡片是 direct-codex-failure:v2 stage=code-generation code=runtime-failure,摘要为「创建 Codex app-server 隔离用户目录失败:Codex 隔离用户目录 路径不能包含符号链接:」。诊断文件同样把路径脱敏,看不出是哪一级目录。
  • 原因:临时目录保留 TMPDIR 或 /tmp 的原始路径。macOS 上 /var 指向 /private/var,未设置 TMPDIR 时 /tmp 指向 /private/tmp。隔离用户目录会检查全部现存祖先,把这些系统符号链接当成私有目录里的链接拒绝。Linux 和 Windows 只在临时路径上确实有符号链接时同样失败。
  • 处理:临时目录确认是普通目录后先解析成真实路径,再创建 codex-home、workspace 和 home。祖先检查不放宽;临时目录内部的符号链接仍然拒绝。
  • 关联:apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs 的 canonical_codex_private_runtime_dir。

AGC 版本探测必须显式提供隔离用户目录

清空子进程环境后缺少 USERPROFILE/HOME 会让 npm 依赖 Windows 后备用户查询,部分机器报 uv_os_homedir / ENOMEM。正常机器探测成功不能证明环境完整,需同时验证子进程环境。版本探测使用临时用户目录和空 npm 配置,详见 AGC 实施计划 的“Web 环境版本探测的用户目录隔离”。

同一祖先下的多个项目会各自弹一次 UAC

  • 现象:AGC 启动页一次挂载出现多个叠在一起的 UAC 提权弹窗;用户点「否」后仍会被再问一次。
  • 原因:windows_acl_repair_target(src-tauri/src/config.rs)对 Managed 作用域返回「第一个读取被拒的祖先」——同一祖先下的多个项目解析到同一个 repair target;而唯一的去重是单次调用内的局部 attempted_targets,跨调用、跨线程都没有记忆。启动页一次并发检查 ≤8 个最近项目,就会并发启动同样多次 powershell -Verb RunAs。
  • 处理:进程级 single-flight(key = (规范化 repair target, scope))+ 结果冷却(成功 30s / 失败 15s / 用户取消 120s)+ 等待窗口 60s 超时按失败关闭;leader 异常退出由 RAII 兜底唤醒等待者。用户取消带稳定标记 AGC_ACL_ELEVATION_DENIED,前端据此不自动重试;用户主动操作会清除拒绝记忆。
  • 不要踩的坑:① 闸门 key 必须归一化 \\?\ / \\?\UNC\ 前缀——最近项目列表里同一项目实测同时存在 \\?\C:\... 与 C:\... 两种写法,按原始字符串做 key 会让同一个目录弹两次 UAC(windows_acl_repair_gate_key);② 冷却必须从结果落库时刻算起,用 leader 起跑时刻会让 120s 拒绝冷却在 UAC 被挂着两分钟时提前过期,紧接着的自动重查立刻再弹一次;③ 复现「多个项目共用同一 target」时,DENY 要写在祖先的父目录上靠继承落入祖先——icacls 直接加在容器自身实测只影响子项(容器自身 GetFileAttributes 仍成功),target 会退化成每个项目自己,repro 不出并发弹窗;④ 夹具路径必须落在 game_creator_private_path_allows_auto_elevation 放行范围内(runtime config dir / .config/genarrative / 打包 AppData / 带 .agent/manifest.json 的项目根),因为提权子进程会按 repair target 再校验一次 scope.allows_path,否则失败关闭。
  • 验证:src-tauri/src/tests/acl_repair_gate.rs(并发只执行一次、冷却复用、拒绝冷却、清除后可重试、follower 超时、leader panic 唤醒等待者、冷却基准、路径写法归一、leader 卡死接管与迟到结果丢弃)。真机复现(无需提权交互即可计数):在 Managed 放行范围内建 8 个带 .agent/manifest.json 的假项目 → 对共同祖先的父目录 icacls <父目录> /deny *<sid>:(OI)(CI)(RX) → 挂载启动页,同时数 powershell.exe 里命令行带 RunAs 的进程数(Start-Process -Wait 会让它一直存活到用户应答)与 consent.exe 峰值:修复前 8 个并发请求,修复后 1 个;把同一目录的 \\?\C:\... 与 C:\... 两种写法一起塞进最近项目,还能验证 key 归一化是否生效(修复前 2 个、修复后 1 个)。
  • leader 卡死的兜底:闸门只有 follower 的有界等待(60s),若提权子进程真的挂死(Start-Process -Wait 无超时),leader_deadline(5 分钟)之前该 key 一直被占住,之后新调用会接管并按新 leader 执行;被接管后旧 leader 迟到的结果按令牌丢弃,不会覆盖接管者。clear_game_creator_acl_elevation_denials 只清「被拒绝」记忆,不清理 running。
  • 关联:src-tauri/src/acl_repair_gate.rs、src-tauri/src/config.rs、issue #498。

Direct 宿主继续请求不能重发原始用户条目

原始 direct_user_item 同时参与历史持久化和模型输入转换;验收或错误反馈更新了 prompt 后,如果发送层仍优先转换原始条目,模型会收到重复的用户输入,而本地历史按 itemId 去重后只显示一次。首次请求与宿主继续必须显式区分:首次保留结构化输入,继续发送当次反馈,原始条目只保留历史与事件关联职责。GUI、CLI 的两条循环都要覆盖;只改反馈文本或清空原始条目不完整。见 Direct 宿主继续请求输入修复。

2026-09-17 工具 schema 声明的上限与真实校验不一致,会表现成「agent 调不动这个功能」

  • 现象:工具在目录里且 UI 可调用,但 Agent 用正常长度背景音乐描述即硬拒;图片编辑长 prompt 也可能被通用校验错误拦截。
  • 原因:schema 声明 prompt 上限 4000,真实背景音乐仅 140;工具桥通用 4000 又低于图片编辑 32000,按 kind 判断散落多处导致声明与执行漂移。
  • 处理:schema、MCP、工具桥和提交校验共用 resource_edit_prompt_max_chars:背景音乐 140、音效 1900、视频/角色动画 4000、图片编辑 32000。schema 逐 kind 声明 maxLength,传输边界只限制信封;超限在付费请求前拒绝并回报真实数字。核对模型可见 schema 与真实执行点,不只确认工具注册。
  • 关联:apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs、apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs、apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs。

2026-09-14 项目写锁的同进程复用判据不能只看 pid

  • 现象:master 的 Project CI / Native shell tests 红在 cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --test-threads=1,12 条用例失败(2439 passed; 12 failed)。断言分三类:① 另一线程持锁时快照读 / project.diff / action_history / command.output_read / steer 不再等待(... must wait for the project consistency lock);② 并发写不再串行化——4 路并行直写撞项目 revision 侧车报 File exists (os error 17),8 线程并发 steer 拿到 [1, 1, 1, 1, 1, 1, 1, 2];③ 别的写通道持锁时 file.write 与恢复安装必须失败关闭,实测变成 ok / 不再报占用。
  • 原因:project/write_lock.rs 的 advisory 复用判据从「自主游戏构建流水线 + 本进程持锁」放宽成「本进程持锁」,而判据只比 .agent/project.lock JSON 里的 pid。pid 只能证明锁由本进程持有,分不清「同一条调用链再次取锁(必须放行,否则自己等自己)」和「本进程另一条写通道正在写(必须继续串行化)」;于是同进程其它线程的写通道也拿到 advisory guard。
  • 处理:复用判据收窄到同线程重入。新增 PROJECT_WRITE_LOCK_THREAD_OWNERS(按锁路径登记真实持锁线程)与 project_write_lock_reentered_by_current_thread:登记在 create_new 成功处,注销在 guard Drop 里并且按路径注销(guard 会被移到别的线程再 Drop,例如写入路径交给阻塞线程池的持有者)。只有当前线程就是该路径的持锁线程(或自主游戏构建流水线)才返回 advisory guard;本进程其余争用继续走有界等待与终态占用。
  • 易错点:① 用「同线程」近似重入后,靠同线程自持锁 + 同线程调用模拟「另一个写者」的用例会失去信号(agent_runtime_file_write_lock_failure_redacts_project_path、recovery_install_respects_the_project_write_lock):它们必须改成在另一条线程持锁,断言才有意义;② 不要用「同进程还有 guard 活着」当重入依据,那等于退回按 pid 放行;③ 同进程跨线程重入(持锁调用链在 await / spawn_blocking 之后于其它线程再次取锁)仍会等满预算并在耗尽时报占用,出现这类现场按 2026-08-27 的处置改用 *_locked 入口复用已有 guard,不要放宽判据。
  • 关联:apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs、src/agent/runtime_actions/project_gates.rs(有界等待预算)、docs/project-memory/plans/【里程碑】项目客户端占用锁收敛-2026-09-14.md、docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md「2026-09-14 项目客户端占用锁收敛」。

2026-09-12 打开 DirectProject 时批量读取专业 Agent 历史导致窗口无响应

  • 工作区的专业 Agent 文本回执 effect 不能只检查 projectSupervisorOnly:DirectProject 同样使用这个工作区壳,默认任务占位行会触发无关的 read_local_conversation 批量调用。
  • 同步 Tauri command 内的权限校验、会话目录扫描和锁等待会占用窗口线程。DirectProject 必须跳过专业 Agent 历史;开发入口仍需要的对话读取在 blocking worker 中执行,权限校验保留在同一后台闭包内。
  • 排障测量完整 IPC 链路并同步采样原生窗口响应。某命令的调用端耗时可能包含前面的主线程队列等待,不能仅凭调用端耗时认定插件启动或上游请求本身缓慢。

2026-09-10 设计 Agent 需要流式 Responses 的原生 output[],不能只靠 tool_calls

  • 现象:新策划报 Provider 返回工具调用但未提供完整 Responses output。模型已经在调工具,.debug/design-agent 里 tool_calls 有值但 output 是 []。
  • 原因:设计 Agent 下一轮要把 Responses output[] 原样接回 input(store=false + 加密 reasoning)。旧 Planning V2 只消费 tool_calls 再由 Runtime 重拼 messages。官方流式常在增量里发 output_item.added / function_call_arguments.done,终态却是不带 /response/output 的 response.completed。旧解析只在 completed 里取原生数组,所以只有新策划会失败。
  • 处理:流式按 output_index 累积全部 output item,arguments.done 写回对应 item;completed 仅在带非空 output 时覆盖。设计 Agent 的空 output 守卫保留。
  • 排查顺序:先看 debug 响应里 output 是否为空、是否同时有工具调用;不要当成模型拒调工具或策划提示词错误。
  • 验证:platform-llm 流式夹具覆盖「空 completed + 增量 item」能拿出可回放 responses_output,以及 completed 顶层 output。

2026-09-05 新建项目锁不要把继承 DACL 当成 UAC 事件

  • 现象:策划 V2 在 GDD 审批提交修改意见时弹出权限窗口,目标是 Documents\Genarrative GameAgent\gameagent-*\.agent\project.lock,随后 AGC ACL 提权修复未成功(exit code Some(1))。
  • 原因:#211 把新建 sidecar 纳入私有 DACL 门禁。父目录已是当前用户独占且禁止继承时,刚 create_new 的锁文件仍会短暂带继承 ACE;生产路径把这类 DACL 不合格送进 --repair-private-acl。独占句柄还会妨碍本进程 SetNamedSecurityInfoW。提权再用 Start-Process -ArgumentList 数组,含空格路径被拆开,helper 参数个数不对并以 1 退出。这不是 V2 审批协议或 Provider 权限请求。
  • 处理:本进程新建对象只在进程内收紧 DACL,不因继承 ACE 自动 UAC。项目锁先写入并释放独占句柄,再 harden,回读内容校验后返回;不再对这把新锁走 prepare_for_read。UAC 仍留给允许范围内的外人本对象;提权命令行改为一条已加引号的 ArgumentList。
  • 排查顺序:先看错误是否点名 project.lock 且含 禁止继承 / exit code Some(1);不要当成策划 V2 或 Provider 鉴权问题。含空格的 Genarrative GameAgent 项目根是复现条件,不是业务失败。
  • 验证:Windows 定向覆盖含空格项目根取锁、新锁已满足私有 DACL、Drop 删除,以及提权参数把带空格路径保留为一个 quoted token。

2026-09-11 首页自动工作区必须使用 AGC 管理目录

  • 现象:首页建项失败且未留下项目目录,先检查默认根是否误设为 Documents;该目录继承 ACL 可能不符合 AGC 受管私有目录门禁。
  • 处理:默认根为 app_data_dir()/projects。当前用户自选目录由 Rust workspace-preferences.json 持有,只接受原生目录选择器授权并经 validate_requested_game_project_creation_root 校验的现存普通目录;不从渲染层接收任意 projectsRoot,也不在目录不存在时代建。自选项目走 user-selected 权限范围。
  • 关联:apps/ai-game-creator-shell/src-tauri/src/workspace_preferences.rs、apps/ai-game-creator-shell/src-tauri/src/commands.rs。

Chat 生成预算字段不能按模型名猜测或失败后自动重放

  • 现象:同一个 OpenAI-compatible Chat endpoint 调用 reasoning 模型时返回 Unsupported parameter: max_tokens;直接把全局请求字段改成 max_completion_tokens 后,旧兼容网关又可能拒绝新字段。
  • 原因:内部生成预算语义与上游 wire dialect 被混在一起。Chat 当前字段是 max_completion_tokens,旧兼容层仍只接受 max_tokens;Responses 和 Anthropic 又分别使用自己的字段。模型名、base URL 和 OpenAiCompatible 标签都不能证明 endpoint 能力,收到 400 后重发还可能重复计费。
  • 处理:在 LlmConfig 上显式声明 Chat token budget field capability;通用兼容配置默认 legacy,已验证的 VectorEngine 专用 client opt-in max_completion_tokens,每次只发送一个字段。内部 max_output_tokens 与 AGC 持久指纹键 maxOutputTokens 保持不变。
  • 验证:序列化测试分别断言 modern / legacy Chat 只出现选定字段,请求级 model override 不改变字段;Responses 继续只发 max_output_tokens,Anthropic 继续只发 max_tokens;AppState 测试断言 VectorEngine client 已显式启用 modern capability。
  • 关联:server-rs/crates/platform-llm/src/lib.rs、server-rs/crates/api-server/src/state.rs、scripts/test-ve-llm.mjs、Issue #143。

工具 JSON Schema 的条件约束必须覆盖运行时默认值

  • 现象:LLM 按工具 schema 生成的参数可以通过结构约束,但参数补默认值后被运行时校验拒绝,白白消耗一次工具修复轮次。例如固定 gpt-image-2 的 UI 工具仍暴露 0.5K,或视频调用省略 model 时 schema 允许 1080p,运行时却默认成 seedance2.0-fast 后拒绝。
  • 原因:通用枚举 schema 被固定模型工具直接复用;JSON Schema 的 if 又用 required: ["model"] 排除了字段缺失场景,而 Serde 默认值只在 schema 校验之后生效。description 只能提示 LLM,不能替代 enum / if / then 的结构约束。
  • 处理:固定模型工具使用与该模型能力一致的专用枚举;可切换模型的图片工具在对象层复用共享 model + image_size 条件约束。条件字段有运行时默认值时,省略字段必须落入默认模型对应的 schema 分支:默认 nanobanana2 的图片工具只在显式选择 gpt-image-2 时收紧尺寸,所以条件保留 required: ["model"];默认 fast 的视频工具则利用字段缺失时 properties.model.const 条件成立的语义,不额外要求 model 存在。运行时校验仍保留为最终防线。
  • 验证:锁定 generate-ui-design.image_size = ["1K", "2K"],三个可切换图片模型的工具都接入共享 gpt-image-2 -> image_size = ["1K", "2K"] 条件,以及视频 fast 条件没有内层 required、其 then.resolution = ["480p", "720p"];同时保留运行时拒绝 gpt-image-2 + 0.5K 与 seedance2.0-fast + 1080p 的测试。
  • 关联:server-rs/crates/platform-editor-agent/src/agent/tools/image_generation_options.rs、server-rs/crates/platform-editor-agent/src/agent/tools/generate_ui_design.rs、server-rs/crates/platform-editor-agent/src/agent/tools/generate_video.rs、docs/【编辑器】画布Agent对话面板-2026-07-03.md。

进程启动意图与 OS 进程身份不能混为一谈

  • 原因:PID 会复用;持久记录不等于活 OS handle。进程启动与账本提交之间退出时,不能凭旧 PID 或一条记录判断副作用未发生。
  • 处理:现役受控命令通过 staged launch / launch gate 在放行执行前完成 durable commit;失败、取消和结果未知分别按真实阶段收束,不自动重放未知副作用。进程树归属使用实际进程组或 Windows Job,不能按旧 PID 接管。
  • 关联:apps/ai-game-creator-shell/src-tauri/src/command_exec.rs、process_session/model.rs。

进程终止必须等子树回收与输出收束

  • 原因:发出一次 signal 不等于进程已退出;PTY 或进程组也不是完整 OS sandbox。
  • 处理:Unix 命令先 graceful wait,再按需要终止同组残留并 wait/reap/drain;Windows 使用 Job 管理子树。Runner 退出调用 shutdown_all_process_sessions_and_wait,等待 live session 与 pending launch 收束;超时不得宣称全部回收。owner 强杀后的 Linux wrapper 与 Windows kill-on-close Job 仍是有效清理边界。
  • 核验:覆盖尾部输出、宽限后强制退出、忽略信号的孙进程和 Windows Job;清理测试不得提前代替被测 owner 生命周期行为。主动脱离进程组的外部 service 不在同组清理承诺内。
  • 关联:command_exec.rs、process_session/shutdown.rs、process_session/model.rs、process_session_bridge.rs。

一次性命令不能把孤儿僵尸误判为仍在执行的进程组

  • 现象:Linux CI 的命令已退出,但 command.exec 返回 needs-reconciliation,后续修复计划或输出读取请求一直等不到;普通 WSL 下相同命令可通过。
  • 原因:容器 PID 1 未回收孤儿 bwrap 僵尸,kill(-pgid, 0) 仍返回成功;主进程已被 wait 回收,后续 leader 启动身份核对必然失败,掩盖了真实退出结果与原本应触发的日志审计错误。
  • 处理:确认 target 终态且回收主进程后扫描 /proc/<pid>/stat,空组或仅含 Z / X 成员无需发送信号;有存活成员仍保留 leader 身份门禁,读取失败保守进入 reconciliation,不放宽未知进程组的信号权限。
  • 验证:隔离 subreaper 夹具覆盖 leader 已回收时的存活后代拒绝、孤儿僵尸接受和空组接受;原有诊断命令、审计失败、命令修复与长输出/历史读取测试在不回收孤儿的 PID namespace 下验证。
  • 关联:apps/ai-game-creator-shell/src-tauri/src/command_exec.rs、apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs。

命令环境变量、代理和进程组不能冒充 OS 沙箱

  • 现象:命令看似使用隔离 HOME / TMP、离线包管理器和不可达代理,仍能直接读取宿主用户文件、用原始 socket 联网,或由 project.verify 的平行 npm spawn 绕开 command.exec 限制。
  • 原因:环境变量和 argv 白名单只约束主动配合的程序,进程组 / Job Object 主要解决生命周期;它们不建立 mount / network namespace,也不能保护 .agent Runtime 控制面。只包 command.exec 而漏掉 command.start 或 project.verify 同样属于 fail-open。
  • 处理:Linux 三个入口统一使用受信任系统 bubblewrap;项目根 rw,.git / .agents / .codex ro,.agent 以 000 空 mount 隐藏,项目外普通用户路径不挂载,network namespace 默认隔离,嵌套 userns 禁用。全局 namespace canary 与项目 mount preflight 都必须在 revision / processId / 目标 program 前成功;任何失败都不回退宿主执行。Windows 在等价 restricted process / AppContainer 落地前继续标记为固定命令 legacy 边界。
  • 验证:不能只断言 bwrap argv。必须运行真实目标和子进程,分别检查工作区写入、宿主 sentinel、四个控制目录、原始 socket、PTY stdin / graceful terminate、Runner SIGKILL 后宿主 /proc 无项目 cwd 进程,以及 unavailable 时 marker 为零。
  • 关联:apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs、command_exec.rs、process_session.rs、project.rs、docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md。

工具链环境根不能把整个用户目录挂进命令沙箱

  • 现象:Runner 以 RUSTUP_HOME=$HOME 启动,或 .rustup 符号链接最终 canonicalize 到 HOME;Agent 随后能在 bubblewrap 内读取 SSH、Cookie 或其它用户文件,并把正文带回命令输出。
  • 原因:只拒绝字面 /home / root / tmp,没有拒绝 /home/<user>,也没有检查 canonicalize 后目录名是否仍与 RUSTUP_HOME / JAVA_HOME / GOROOT / DOTNET_ROOT 类型匹配。
  • 处理:外部工具链环境根 canonicalize 后必须通过窄叶目录校验;用户 HOME、HOME 符号链接目标和类型不匹配目录全部在 mount preflight 阶段失败关闭。不要为了兼容任意自定义环境根放宽成“只读就安全”。
  • 验证:直接 HOME、.rustup -> HOME 均返回错误且目标 program 零执行;真实 .rustup 叶目录仍可只读挂载,Cargo fixture build 继续通过。
  • 关联:apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs、command_exec.rs、docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md。

pre-exec 固定 FD 映射不能逐项覆盖源描述符

  • 现象:可信 launcher 的单项测试都通过,但并发 project.verify 偶发在第二次 launch 被记为 failed;失败项单独重跑又恢复正常。
  • 原因:父进程创建 socket/file 后得到的源 FD 数值不固定。若逐项 dup2(source, 0/4/5/6),前一次目标 FD 可能正是后一条映射尚未读取的源 FD,导致 status、block 或 trampoline source 被静默替换。测试并发度改变打开 FD 分布,因此表现为偶发。
  • 处理:在父进程进入 spawn 前先用 F_DUPFD_CLOEXEC 把所有源复制到 64 以上互不重叠的 owned FD,并保持到 child-created;pre-exec 只把这些稳定高位 FD dup2 到固定 0/4/5/6。不能等到 pre-exec 才复制原始源,因为 Command 的 stdin/stdout/stderr 安装可能已覆盖原本占用 0/1/2 的源 FD。
  • 验证:并发执行全部 project verification 测试;同时真实运行 bwrap staged marker 用例,确认 child-created、block、ready、commit、exec 和目标退出链均稳定,目标 argv/env/FD 不含 nonce 或控制 socket。
  • 关联:apps/ai-game-creator-shell/src-tauri/src/command_sandbox_trampoline.rs、command_sandbox.rs、command_exec.rs、project.rs。

bwrap 的命令分隔符不能从目标 argv 末尾反查

  • 现象:普通命令握手正常,但 cargo test -- --nocapture、npm forwarded args 等包含独立 -- 的合法目标参数可能在 sandbox preflight 或 staged launch 期间提前执行原目标。
  • 原因:launcher 用 rposition("--") 查找 bwrap 自己插入的命令分隔符,误命中目标 argv 里的最后一个 --;截断后原 target executable 仍位于 bwrap COMMAND 位置,trampoline 被追加成目标参数而不是替代目标。
  • 处理:构造器保证 bwrap options 与 COMMAND 之间只有第一个独立 -- 是 launcher 分隔符;preflight 和 stage 都取第一个位置。不要从用户目标 argv 的末尾推断结构边界。
  • 验证:真实 staged bwrap 用例必须让目标 argv 带独立 --,同时断言 sandbox-ready/commit 前 marker 为零,commit 后才执行成功。
  • 关联:apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs、command_exec.rs。

PTY 私有控制 FD 不能假设会被 bubblewrap 透传

  • 现象:process-session wrapper 在 portable-pty 之后成功创建 fd 3 控制 socket,但 trampoline 收不到 ready/commit 帧;如果直接改用 fd 0,真实 target 又失去交互 stdin。
  • 原因:portable-pty 在 wrapper exec 前关闭全部 fd 3 以上描述符;wrapper 重新创建 fd 3 后,bubblewrap 仍只保留 stdio 和被 --json-status-fd / --block-fd / --ro-bind-fd 明确引用的描述符,未引用 fd 3 不是可靠 COMMAND 继承通道。
  • 处理:Runner 与 wrapper 先用 abstract Unix socket bridge 传递私有 launch plan;wrapper 内部 gate 继续通过 bwrap 会保留的 fd 0进入 trampoline。process-session trampoline 不把控制 fd 0传给 target,而是确认 fd 1 是 PTY 后复制同一 slave作为 target stdin;一次性命令模式仍显式使用 /dev/null。
  • 验证:真实 PTY 测试必须完成 readiness、stdin echo 和 terminate;trampoline 测试同时断言一次性 stdin 为 null、process-session stdin 可读 PTY,target fd 3/4/5/6 不存在,bridge endpoint/nonce/control frame 不出现在 transcript 和公共持久面。
  • 关联:apps/ai-game-creator-shell/src-tauri/src/process_session_bridge.rs、process_session.rs、command_sandbox.rs、command_sandbox_trampoline.rs。

graceful terminate 不能先杀承载 target 的 wrapper 进程组

  • 现象: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 后用 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 超时

  • 现象:慢磁盘让 sandbox-ready 后的 child 在父侧持久化完成前自行退出,父侧随后把零执行误记为 launch-unknown;损坏的 failed + launch-unknown + needsReconciliation=false record 又可能被 active/final/idle 扫描漏掉。
  • 原因:child 与父侧各自维护短 timeout,没有统一 commit/abort 决策;record 校验只检查枚举和值存在,没有约束 status、sandbox、target、failure、reconciliation 与时间戳的合法组合。Windows 如果在 action 去重前调用 durable callback,还会在同 action replay 时重复推进 revision。
  • 处理:sandbox-ready 后 child 阻塞等待父侧显式 commit 或 abort,父侧失败时发送 abort 并回收树。v3 读取使用封闭状态矩阵和 started <= ready <= exec <= terminal <= updated 的逐项可选时间校验;旧 boot prepared/launching 及同 action start replay转成 target unknown。非 Linux durable callback 只放在 existing action miss 分支,started/ready/exec 使用同一时间点。live registry 必须与 durable record、pending reservation 合并参与 capacity/final/idle,不能因 record 缺失失败开放。
  • 验证:durable callback 延迟超过旧 3 秒时 target marker 在 callback 内必须仍不存在、commit 后才出现;构造 launch-unknown/start-audit/target-exec 的非法组合均拒绝读取,旧 boot launching 和 same-action replay必须变成可再次读取的 reconciliation,同 action Windows 测试只调用一次 callback;删除 live record 后 final/idle 仍被 registry 阻断。
  • 关联:apps/ai-game-creator-shell/src-tauri/src/process_session.rs、process_session_bridge.rs、docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md。

image.inspect 的桌面和移动截图别名必须绑定当前 run

  • 现象:真实桌面/移动试玩已经通过并生成截图,Supervisor 随后反复调用 image.inspect 的 desktop.png / mobile.png,但工具只接受完整项目相对路径,最终耗尽 planning 循环。
  • 原因:Provider 能看到固定截图名,却看不到持久证据路径中的 Agent、run 和 revision;让模型猜完整内部路径既不稳定,也会扩大私有路径暴露面。
  • 处理:只为精确 basename 提供受控别名,解析到当前 Agent、当前 run 下最新数字 revision;显式路径继续按原规则处理。严禁跨 run 搜索“最近截图”,否则旧轮次成功证据会污染当前验收。
  • 关联:apps/ai-game-creator-shell/src-tauri/src/image_inspect.rs。

Unix 文件身份复核不能假定 Linux 的 dev_t 类型

  • 现象:AI 游戏创作 Tauri 壳在 Linux CI 编译通过,但 macOS 上会在 Agent DB、External Runner owner 和 tool-plan handoff 的 fstatat 身份复核中报 i32 == u64 类型错误;Tauri 失败后配套后端收束,终端还可能短暂出现 SpacetimeDB 订阅连接失败的连锁日志。
  • 原因:libc::stat.st_dev 跟随平台 dev_t,macOS 为有符号整数,而 std::os::unix::fs::MetadataExt::dev() 统一返回 u64;直接比较会把 Linux 的类型偶合误当成 Unix 通用契约。
  • 处理:与 Rust 标准库的 Unix MetadataExt 实现保持一致,先把 st_dev / st_ino 规范为 u64,再与 metadata.dev() / metadata.ino() 比较;设备号、inode 和文件类型三重检查均必须保留。
  • macOS 测试夹具:std::env::temp_dir() 可能返回 /var/folders/...,而 /var 是系统兼容符号链接。需要真实项目根的 Runtime 测试应先 canonicalize 已存在的临时根目录,再创建唯一子目录;不得为了让夹具通过而放宽生产 Runtime 的项目根及祖先符号链接拒绝规则。
  • 异步测试隔离:测试触发后台 continuation 后,必须等待对应 Agent lane 完整释放,再删除项目夹具或安装下一项全局 mock 配置;否则前一项后台任务可能抢占后一项的唯一 mock 响应,形成只在全量顺序执行时出现的跨测试污染。
  • 验证:macOS 本机运行 cargo check --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml,并复跑 Agent DB、project owner 和 tool-plan handoff 的 Unix 相对句柄替换检测;Linux CI 继续覆盖原有安全回归。
  • 关联:apps/ai-game-creator-shell/src-tauri/src/project.rs、runner.rs、tool_plan_handoff.rs。

仓库回退配置模板不能被读取通道私有化锁定

  • 现象:Windows 上 apps/ai-game-creator-shell/game-creator.config.json 莫名其妙被"加锁"(DACL 被剥成只剩一个陌生 SID,连 Get-Acl 都 unauthorized),开发 agent 和其他用户无法修改,cargo 也因 include_str! 读不到文件而不能编译;手动解锁后过一段时间又被锁。
  • 原因:无 AppHandle 的开发 CLI(llm-status、agent-run、agc:test:chat 等)经 game_creator_config_paths() 从 CWD / current_exe 向上回溯 8 级探测到 worktree 里的 git 跟踪模板后,读取走了为 AppData 私密凭据设计的私有通道 open_project_private_regular_file → prepare_game_creator_private_path_for_read;该函数名为 "for read",在 Windows 上却无条件收紧目标 DACL 为"仅当前进程用户、禁止继承"。沙箱 agent 是其 checkout 文件的 owner,校验通过后被静默私有化,其他账号全部 Access Denied。
  • 处理:配置读取按路径归属分流——read_game_creator_config_file 只对位于 game_creator_runtime_config_dir()(AppData 托管目录)内的真实凭据走私有加固读取;仓库旁边的回退模板 / local 覆盖一律走 open_project_snapshot_regular_file 非变异快照通道,读取绝不修改 owner / DACL。这与 open_project_private_regular_file 注释中"非用户明确选择的文件用 snapshot 读"的既有原则一致。
  • 教训:任何名为"读前准备"的函数若附带权限收紧副作用,都必须按路径是否属于本进程托管范围设白名单;共享仓库文件、git 跟踪文件永远不在加固范围内。排查"文件莫名被锁"时优先查 DACL owner 是哪位 SID,再倒推哪个进程以该身份运行过。
  • 验证:tests::configuration::fallback_template_read_stays_on_snapshot_channel_outside_runtime_dir 与 runtime_config_read_stays_on_private_channel_inside_runtime_dir 锁定两条通道的分流;node scripts/check-config.mjs 通过。

外部生成迟到结果不能覆盖已变更文件

  • 原因:外部图片生成等待期间,目标文件可能已被另一写入更新;只有 replaceExisting 授权而不核对原文件,会用迟到结果覆盖新内容。
  • 处理:请求前冻结原路径内容指纹,取得写锁准备提交时复算;替换授权回调与临界 rename 前再次核对。任何漂移拒绝覆盖并保留当前文件,不能先删除正式产物再重新生成。
  • 辨识:分别区分“生成期间发生变化”和“提交临界点发生变化”,不要把 stale fingerprint 归为 provider 生成失败。
  • 关联:apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs。

待用户确认的 Agent 工具不能依赖模型自行结束回合

  • 现象:画布 Agent 已生成有效工具规划,却最终只保存 ERROR max turns reached: 3,助手文本和待确认工具卡都消失。
  • 原因:八类画布工具的 call() 只返回待用户确认的规划结果,但 function-calling runner 在成功工具后仍继续请求 LLM,只靠 prompt 要求模型不再重试;模型连续返回工具调用直到上限后,错误结果又丢弃此前累积的输出。
  • 处理:工具通过框架契约显式声明 requires_user_confirmation;当本批全部工具都成功且等待确认时,runner 在处理完整批次后立即返回已有助手文本和工具结果。未知工具、参数错误、hook skip、普通连续工具和不可解析响应仍继续受 max_turns 门禁保护。不要用单纯提高轮次上限掩盖终止条件缺失。
  • 验证:runner 回归测试必须同时覆盖“待确认工具只调用一次 LLM 并成功结束”“普通连续工具仍会触发 max-turn 门禁”“多工具按数组顺序执行”“request 级 system prompt 真实进入请求”;公共 prompt 在无工具时仍必须包含 runner 所需的 JSON 响应格式,且不得宣称并发执行。
  • 关联:server-rs/crates/platform-agent-harness/src/run.rs、server-rs/crates/platform-agent-harness/src/tool.rs、server-rs/crates/platform-editor-agent/src/agent/tools/。

待确认工具的 prompt 不能使用全局禁止重发话术

  • 现象:为防止用户在对话中说“确认 / 可以 / 取消”时重复生成待确认卡片,prompt 加入“不得重新发起相同工具调用”后,模型在用户随后明确提出新生成、修改或重做请求时也拒绝调用工具。
  • 原因:LLM 容易把面向“当前确认 / 取消意图 + 特定 pending 卡片”的限制过度泛化为跨回合、跨意图的全局禁止;单看工具名或参数相似度不能区分“重复确认旧卡片”和“用户明确发起新任务”。
  • 处理:prompt 只用正向条件句描述当前回合:确认或取消意图确实匹配某条现存 pending 卡片时,引导用户点击该卡片按钮,本条意图不生成新 tool call。不添加全局的“禁止重发相同工具”规则。cancelled 卡片不再处理;用户要求修改、重做或新任务时正常发起新调用,pending 卡片不阻塞无关请求。
  • 验证:业务 prompt 契约测试要同时锁定“匹配 pending 时引导确认 / 取消按钮”“cancelled 后可发起新调用”和“pending 不阻塞无关新请求”;模型实测必须另外覆盖同工具名的后续新任务,确认不会因过度泛化而拒绝。
  • 关联:server-rs/crates/platform-editor-agent/src/agent/prompt.rs、docs/【编辑器】画布Agent对话面板-2026-07-03.md。

Agent 终态失败不能吞掉已发生的工具事实

  • 现象:同一轮 prompt 中前面工具已经成功生成待确认结果,但后续工具、hook、completion 或 max_turns 失败后,API 只保存最后一条 ERROR ,已执行工具和用户本轮语义从会话历史中消失。
  • 原因:runner 只返回单一 PromptError,或者直接向 committed memory 逐步写入,无法区分“尚未发生外部工具事实,整轮可回滚”与“已发生工具事实,只能提交并闭合错误”。工具失败若被压成字符串,调用方还会丢失 kind、retryable、fatal 和原始 output。
  • 处理:用 PromptRunError { error, partial_outputs } 保留失败前输出,并将本轮 memory 先写入 staged buffer。无工具活动失败时整体回滚 staged 增量;有成功或失败工具活动时提交已发生事实,并追加 terminal error closure。api-server 按 partial_outputs 顺序先持久化成功工具的 not_completed 待确认消息,再追加 ERROR 终态消息;ToolFailed 保留给调用方做诊断和流程决策,不伪装成成功确认卡。
  • 取消边界:不能在 prompt future 内对 agent.memory.take() 后跨 await 持有,也不能用统一 VecMemory staging 绕过自定义 memory 的限长、摘要或脱敏规则。AgentMemory::begin_staged 必须产生行为等价、写入隔离的 StagedAgentMemory,成功或已有工具活动时显式 commit(),直接 drop 才表示回滚。外部 drop 若发生在工具完成后,guard 必须提交结果与取消闭环;若工具仍在执行,至少提交“已启动、结果未知”事实,供后续 reconcile。正式总 deadline 应作为 runner 内部 future 终止 completion;工具开始前检查 deadline,一旦开始则不能中途 drop,必须等待结果后再携带 partial outputs 收口。外层 timeout 只适合作为进程级最后保险,不能承担业务收口。
  • 验证:至少覆盖“无工具 completion 失败回滚 staged 用户消息”“非 fatal 工具失败对调用方暴露 kind/retryable/fatal/output”“成功工具后终态失败保留 partial tool output”“有工具活动时 committed memory 末尾存在 error closure”以及“API 增量中待确认工具位于 terminal ERROR 之前”。
  • 关联:server-rs/crates/platform-agent-harness/src/run.rs、server-rs/crates/platform-agent-harness/src/tool.rs、server-rs/crates/platform-editor-agent/src/agent/prompt.rs、server-rs/crates/api-server/src/editor_agent/api.rs。

画布 Agent 的规划请求不能关闭瞬时失败重试

  • 现象:美术 Agent 对话返回红色错误气泡 completion error: LLM 请求超时,累计尝试 1 次;HTTP 本身仍返回 200,前端 20 分钟 transport timeout 没有触发。
  • 原因:规划请求虽然有 Agent 专用单次 timeout,但 vector_engine_llm_client 把 max_retries 硬编码为 0;VectorEngine gpt-5.4-mini 的偶发长尾、连接超时或可重试上游状态会在第一次失败后直接持久化成 system error。framework 的英文 completion error 前缀也被原样暴露给用户。
  • 处理:120 秒改为前端软提示阈值:POST 仍 pending 时显示不入库的“仍在处理中,请耐心等待”;provider 明确断开/失败才写正式错误。专用 provider 单 attempt 使用 8 分钟 hard timeout,请求发起阶段读取 GENARRATIVE_LLM_MAX_RETRIES,但画布 Agent 最多重试 1 次且重试退避最多 60 秒。不要只计算单次 complete 的最坏时间:runner 还可因非法 JSON/工具校验失败进入后续轮次,必须从 handler 入口开始计算 18 分钟总 deadline,进入 agent.prompt(...) 时扣除会话锁/上下文准备已用时间,为持久化和前端 20 分钟 timeout 留出余量。响应头后的体读取/解析错误按明确失败收口,必须使用真实 attempt 计数;规划、配置和定价错误对用户统一为中文,原始诊断只记后端日志。重试发生在任何生成工具执行前,不会重复提交生成任务或扣费,不要通过提高前端 timeout 或 runner max_turns 掩盖 provider 重试缺失。
  • 验证:platform-editor-agent 测试锁定 8 分钟 hard timeout 与中文错误;前端 fake timer 用例锁定 120 秒前只显示思考动画、到点后显示耐心等待、成功/失败后移除;platform-llm 回归用例锁定第二次 attempt 成功响应头后的 body timeout 仍报累计 2 次;api-server 测试锁定专用 client retry、18 分钟整体 deadline 与中文直达错误。运行态排障按同一 request id 对齐 platform_llm failure stage 与 /messages 总耗时,并确认仍 pending 的请求不再在 120 秒形成错误气泡。
  • 关联:server-rs/crates/platform-editor-agent/src/agent/agent.rs、server-rs/crates/platform-agent-harness/src/error.rs、server-rs/crates/api-server/src/state.rs、src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts、src/components/image-editor/EditorAgentConversation/MessageBubble.tsx、src/services/image-editor/editorAgentClient.ts。

Runtime 不能在外部工具返回后才首次记录执行意图

  • 现象:Runtime 调用工具成功后准备写 observation,但进程在写入前崩溃;重启后只看到 queued action,于是再执行一次外部副作用。
  • 根因:把“调用返回”当成 durable 事实,缺少工具调用前的持久 executing checkpoint;网络、文件、命令和外部 API 都不能因为“看起来幂等”就自动重放。
  • 处理:先以 CAS 单独 commit queued -> executing,成功后才调 ToolHost;调用返回后再 commit observation。恢复见到 executing 或 ToolHost 返回 Unknown 时只能进入 reconciliation,不得自动重执行。重复 resume 不得继续增 revision 或重复 event。
  • 验证:在“ToolHost 已调用、observation commit 失败”处注入故障,序列化快照并用新 engine 重载;断言重复 resume 后 ToolHost 计数仍为 1,且只有显式 reconcile observation 才恢复 running。

大型 async future 要在合适栈上轮询并先装箱

  • 原因:debug 构建的大型 async 分支与嵌套泛型 helper 会形成巨大 poll frame;不是只有递归才会 stack overflow。在 helper 底层才装箱,调用方可能已经把完整 future 留在默认栈上。
  • 处理:大型 future 在进入泛型/轮询边界前装箱;AGC 应用 async runtime 统一使用 AGENT_RUNTIME_BACKGROUND_WORKER_STACK_BYTES(16 MiB)深栈 worker。不要通过 CI 的 RUST_MIN_STACK 掩盖生产入口问题。同步 Tauri 命令和 Drop 可能没有 Tokio 上下文,派发经当前宿主 helper/tauri::async_runtime。
  • 核验:在不提升默认线程栈的条件下验证实际入口和轮询边界,单纯“这次没崩”不证明运行位置正确。
  • 关联:agent.rs、main.rs 的 install_agent_runtime_async_runtime_with_deep_stack、agent/codex_app_server/mod.rs。

Provider 可扩展不能用一个全局 protocol 枚举代替实例隔离

  • 现象:把 openai_chat / openai_responses / anthropic 直接当 Provider 身份,注册第二个同协议 endpoint 时发生 ID 冲突;或为方便调用把 API Key、base URL、raw-log 目录放进全局状态,并行请求后日志串目录。
  • 原因:wire protocol 是 adapter 能力,Provider instance 才是配置与资源所有者;两者被闭集枚举合并后,无法表达同协议多租户/多 endpoint。
  • 处理:core 同时校验 ProviderInstanceId + ProviderProtocolId,registry 只以 instance ID 索引 adapter;adapter 内持有独立 LlmClient。新协议通过实现 trait 注册,新实例通过自定义 instance ID 注册,都不得修改 core match。
  • 验证:至少同时注册两个同 protocol 实例,证明 descriptor/lookup 互不污染;对每项未声明能力断言 adapter 调用计数为 0;并行 raw-log 测试必须使用两个显式临时目录,不用串行化掩盖错误路由。

Provider schema 能力不能从统一工具标记直接推断(2026-08-03)

  • 现象:把 OpenAI 风格 strict 原样透传给完整 Anthropic 工具目录,单个 schema 不支持的约束或全请求工具 / optional / union 上限会让整次 planning 返回 400。
  • 处理:能力不能从 apiKind=anthropic 推断;只对已验证 endpoint/model 显式开启,AGC 当前仅自动识别官方 HTTPS endpoint 与 Claude 4.5+ 版本化 model id,旧模型、未知别名和第三方兼容网关默认关闭。协议适配层用官方支持关键词白名单生成 Anthropic 专用传输 schema,对已知不支持约束仅从传输副本剔除,未知关键词、不可解析 / 递归 $ref 和复杂度超限均失败关闭为 non-strict,不删工具或修改调用方原 schema。真实 live 样例应包含 $defs/$ref 嵌套 schema,并使用官方 Anthropic endpoint,第三方兼容网关不能替代官方能力证据。

可选 MCP server 的坏目录不能拖垮全部工具(2026-08-03)

  • 现象:可选 server 已成功连接,但返回超限 schema、重复 tool identity 或要求未支持 task-mode 时,整个 MCP catalog 和本轮 Agent planning 一起失败。
  • 处理:连接、tools/list、工具归一化与聚合容量都使用同一 required / optional 边界。optional 将该 server 投影为 connected=false + error + tool_count=0,required 保持失败关闭;被包入 action.input 的 $ref 只重定位当前 document 根的 # / #/... JSON Pointer,命名 anchor、外部 URI 与带 $id 的 schema resource 内 fragment 不得改写。

GUI owner 锁不能替代逐 boot 的事件接收端登记(2026-08-05)

  • 现象:GUI 首次启动后 manifest 事件转发正常,但 Runner 被替换为新 boot 后只剩 owner 锁和 endpoint 可用,后台更新不再到达 GUI;或者 attach 响应只确认 owner,客户端却误记当前 boot 已完整登记,后续 ensure 不再重试。
  • 原因:把 OS owner 生命周期约束与进程内事件 sink attachment 混成同一状态,或在 ensure_external_agent_runner 之外执行一次性 attach;测试若用 actionId 等无关字段代替真实 sink port/token,也无法证明新 boot 重放的是可用接收端。
  • 处理:GUI 按规范化 AppData 私有登记真实 sink port/token,ensure_external_agent_runner 的 endpoint 复用和新 Runner 就绪两条成功路径都按 bootId 重放。同 boot 成功后幂等,新 boot 必须重挂;RPC、attached 或 eventSinkAttached 任一失败或缺失都不得记录成功 boot,并允许同 boot 后续重试。不同 AppData 不共享登记,未登记 CLI 不触发 attach;sink token 不进入日志、错误或公共状态。
  • 验证:分别覆盖真实 port/token 跨 boot 原样重放、同 boot 幂等、新 boot 重挂、普通 attach 失败、eventSinkAttached 缺失与 false 后同 boot 重试、AppData 隔离和未登记 CLI 零副作用。

Codex CLI 进程退出不能代替协议终态

  • 原因:启动成功或 exit code 0 不证明 JSONL 回合完整;GUI 的安装/PATH 状态也可能与交互终端不同。
  • 处理:CLI 解析要求完整 turn.completed 与有效最终消息,缺终态、超时、异常退出和破损输出按真实错误收束;CLI 不可用时显式失败,不静默换执行器。版本与包体发现按现役 codex_cli.rs 核对,超时必须回收进程组且 stderr 遵守脱敏边界。
  • 核验:CLI 协议成功与真实认证/网络结果分别证明,不因 fake server 或退出码为 0 宣称真实 Provider 成功。
  • 关联:agent/codex_cli.rs。

空 MCP table 覆盖不能清除用户 Codex 配置

  • 原因:Codex -c 对 table 做合并,mcp_servers={} 不代表删除已有 server;app-server 与一次性 exec 的隔离参数也不同。
  • 处理:AGC app-server 使用隔离 CODEX_HOME,当前内置 agc_tools 与客户端显式启用的第三方 MCP 按合同注入,不能继承用户全局 MCP、hooks/apps 或秘密。认证走 AGC 平台代理/显式 Key。
  • 核验:检查实际 thread 启动后的工具目录与 startup 事件,只看到 initialize 成功不能证明隔离。
  • 关联:AGC 实施计划,以当前 Direct 合同与 OAuth 决策为准。

隔离 CODEX_HOME 不等于隔离所有原生能力

  • 原因:用户 Skill 发现还可能读取 OS HOME;dynamicTools=[] 只清宿主动态工具,read-only 也不等于工具目录不含命令、搜索或多 Agent。
  • 处理:隔离配置时同时核对 HOME/USERPROFILE/APPDATA/LOCALAPPDATA 与仓库发现边界;在模型看到目录前落实当前 feature 与权限配置。Direct 原生文件/命令/Skill、审核 MCP 与禁用能力按现行合同区分。
  • 核验:实际发现根、声明目录和执行许可都要验证;事后 item/completed 拒绝不足以防止已发生副作用。
  • 关联:AGC 实施计划的宿主验收与执行许可合同。

2026-09-12 app-server other 不代表 dev 上游故障

  • 现象:客户端显示 codex-app-server-error:other,但 DirectProject 的项目历史没有 agc_cocos_execute item。
  • 证据边界:dev /api/llm/models、流式 /api/llm/responses 和带 agc_cocos_execute 工具的 Responses 探针均可返回成功;这只能证明 dev 契约和模型路由可用,不能证明客户端本次请求已经到达 dev。
  • 处理:app-server 失败分类必须优先读取安全的 message / additionalDetails / codexErrorInfo,把 stream must be true、超时、鉴权、请求过大和连接断开投影为稳定类别;禁止把上游正文、token、URL 查询参数写入日志。DirectProject 工具调用只有在 turn/start 成功后才会出现,不能用“没有工具 item”反推 Cocos bridge 失败。

多 Agent 共享一个 Codex app-server 会放大单点终态丢失(2026-08-10)

  • 现象:多个节点最初已有 started -> completed,随后一个 app-server stdio 连接关闭,同一秒多个仍在途节点一起进入 needs-reconciliation;单看 threadId 不同会误以为节点已经进程隔离。
  • 原因:pool 只按 LLM 凭据和路由复用进程,节点身份只用于进程内 thread map。任一 stdout framing、子进程退出或连接故障都会 drain 整个进程的 pending/turn router,使所有共享节点同时失去可信终态。
  • 处理:pool key 必须包含 projectId + agentId + sessionId + runId,每个权威节点直接持有独立 app-server 子进程;同节点 turn 还要串行,不能向同一 thread 并发 turn/start。只发送当前 CLI schema 定义的字段;stderr 使用有界内存尾部并先脱敏再进入 Runner 诊断。
  • 验证:至少两个节点并发各跑多轮,确认存在两个 app-server PID;终止其中一个后只有对应节点进入 reconciliation,另一个仍能收到 turn/completed。旧 AppData 缺 agentMode 且含非 Responses 路由时必须保留 provider,不能在项目自动恢复时批量失败。

隔离 Codex app-server 会误吃代理的 ChatGPT 额度头(2026-08-20)

  • 现象:同一自定义 Responses endpoint 和 API Key 直接 HTTP 为 200,普通用户 HOME 下的 smoke 也完成,但隔离 CODEX_HOME/HOME 的 app-server 在真正发请求前返回 usageLimitExceeded,并投影 credits balance 0。
  • 原因:开发网关把 X-Codex-* ChatGPT 账户额度头附在 API Key Provider 响应上;隔离进程没有用户 ChatGPT 额度状态覆盖,Codex 0.147 将这些头当作本地账户限制。模型、Key、MCP 和 Skill 均不是根因。
  • 处理:只为 Direct conversation 启动随机 loopback /responses 流式代理;拒绝其它方法/路径并剥离 X-Codex-* 账户头。不要复制用户 auth.json 来掩盖问题,也不要把 Provider 切换当根因修复。
  • 验证:同时记录上游直接 200、未过滤时 credits=0/usage-limit、过滤后真实 turn completed;代理测试必须证明无 Bearer 拒绝、路径收窄、正文流式保留和额度头不下传。

Codex MCP 子进程不适合直接启动桌面浏览器(2026-08-20)

  • 现象:同一 agc_browser_playtest 在普通进程中能返回双视口截图,但从 Codex 启动的 STDIO MCP 子进程调用时 Chrome 启动超时。
  • 原因:MCP 子进程继承隔离 HOME/AppData 和 Codex 进程约束;把真实浏览器或 GUI 登录态硬塞给子进程既不稳定,也扩大凭据边界。
  • 处理:STDIO MCP 只做 schema 与协议适配;浏览器和付费美术通过随机 loopback 工具桥回到持有项目、登录态和正常桌面环境的客户端主进程。桥只绑定当前项目、限制请求大小和审核工具名,返回脱敏文本与有界 PNG。
  • 验证:必须从真实 Codex thread 发起 MCP 调用并观察 desktop/mobile readyState=complete 与两张截图;直接运行 MCP 二进制成功不能替代该链路。

Direct 美术工具不能把“包存在”当成“本次已生成”(2026-08-23)

  • 原因:只凭旧包存在就短路,或恢复账本不比较当前意图,会让明确重做返回旧素材;切片只落文件不登记也无法形成权威资源投影。
  • 处理:审核工具显式使用 reuse-or-create / regenerate,模式由 Codex 按用户语义选择,客户端不再用关键词判高层意图。活动回合、稳定 clientTurnId 与冻结 brief 摘要绑定付费链;相同 completed 请求只重放原结果,不重复扣费。项目绑定、权限、锁和未知结果对账继续有效。
  • 恢复:换回合先持久 resetting 再清阶段,补偿保留 prepared/accepted 账本与原 operation/幂等键。严格图集调用前冻结完整旧合同及各文件/asset 的存在性;重启先对账严格事务,完整新合同收口、完整旧合同才补偿前两张图,混合/漂移保留未知结果,不能局部回滚成功图集。旧规范图/背景图必须有可信旧字节与 manifest entry 才允许 regenerate。
  • 投影:实际切片须经私有回执、公开清单、源图与顶层登记交叉验证,零切片保留告警;部分、opaque、重复和缺回执不能伪装 Canvas 权威。
  • 对话:成功 assistant 在 Rust 返回前按稳定身份落盘;“同回合仍运行”只作瞬时提示,不抢占终态。恢复扫描按每个未回答 User 判断,不因后轮已回答漏掉前轮;claim 在写入明确收束后释放,终态及时清理,避免无界增长与重复付费。
  • 关联:AGC 实施计划的 Direct 美术与恢复合同,agent/direct_runtime/mod.rs、agent/direct_project_history.rs。

锁文件回收的判定与删除必须基于同一份快照(2026-09-09)

  • 现象:两个实例同时恢复同一个崩溃项目时,后判定的一方可能删掉另一方刚装上的活锁;remove_file 的 NotFound 还会被当成硬失败,直接报“清理失效项目写锁失败”。
  • 原因:project_write_lock_can_be_reclaimed 只是快照观察,调用方拿到 true 后无条件 unlink;helper 还分别重读 createdAt / pid / processStartedAt,并发替换会拼出“旧 inode 的死 PID + 新 inode 的启动身份”。
  • 处理:payload 只解析一次并连同字节一起快照;删除前重新核对字节,只有内容仍是判定时的内容才 unlink;文件已消失或被替换时返回 false 并重试 create_new,不报错。
  • 补充:project_write_lock_file_modified_seconds 读不到 mtime 时不要返回 0——纪元 0 会被算成极大年龄,把保守判定反转成“立刻回收”,甚至把活持有者当 PID 复用抢走;要用 Option 区分“mtime 未知”和“mtime 等于纪元 0”。
  • 关联:apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs。

2026-09-10 Direct 写通道零等待取锁把毫秒级竞争放大成整轮阻断

  • 现象:写入几十毫秒即失败而只读正常,可能是零等待入口放大同进程短竞争;失败后锁文件消失不否定曾有争用。
  • 处理:Direct 写路径复用有界等待并区分持锁身份、权限与其它错误。Windows delete-pending 可能返回 ACCESS_DENIED 且目标瞬间不可见:重试性按平台/原始错误码判断,权限终态在等满预算后再核对,不能用一次 exists() 判断。跨平台测试须显式传平台,Windows 5 与 Unix errno 5 不同义。
  • async 边界:同步等待和整条写路径经 spawn_blocking,不能占住共享 Tokio worker;current_thread runtime 的心跳可验证等待期间执行器继续推进。
  • 排障:ownerIsSelf 仅作 PID 线索;付费 pendingOperations 与写锁不同;长期零字节 manifest OS 锁也是另一种锁,不按存在性残留锁处理。
  • 关联:agent/direct_tool_bridge.rs、project/write_lock.rs。

2026-09-11 Codex app-server 会 1:1 回显注入内容:读侧上限不得小于写侧允许量

  • 原因:开启 raw events 后,注入的单条 item 会原样回显;写侧允许量大于读侧时,合法历史注入被误判成连接故障。触发量是单个 item,不能只看历史总字节数。
  • 处理:读写共用 GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES(32 MiB),写侧拒绝超限而不截断;注入前分别检查单 item(留回显信封余量)与整份载荷。固定超限属于不可重试,提供恢复提示,不能重复提交只会变大的同份历史。
  • 核验:用真实 bundled app-server 验证接收与回显;实验 raw API 需初始化能力声明,stdin 用管道保持连接。stderr 摘要不证明故障原因;探针残留子进程只按自身路径/身份清理。
  • 关联:agent/codex_app_server/mod.rs、agent/direct_runtime/mod.rs;DirectProject 原始历史与异常恢复。

2026-09-16 同步 Tauri 命令里 tokio::spawn:点「终止」整个客户端 abort 闪退

  • 现象:同步取消命令在 WebView IPC 线程调用 tokio::spawn,会因无 reactor 上下文 panic;panic 越过回调边界可导致整个 GUI abort。
  • 处理:取消、lease Drop 与 turn Drop 统一经 spawn_codex_app_server_task / tauri::async_runtime::spawn,使用应用注册的深栈 runtime。判断依据是调用线程可能无 Tokio 上下文,不是函数看起来异步。
  • 核验:从普通 std::thread 调用派发入口;只用 #[tokio::test] 测守卫路径会漏掉同步命令线程。Release GUI 没有 stderr/panic hook 时,日志无异常不能排除 panic,应结合 WER/minidump 与调用线程判断。
  • 关联:agent/codex_app_server/mod.rs、main.rs、commands.rs。

2026-09-21 DirectProject 结构化消息不能逐个拒绝空白文本片段

  • 现象:多行正文、末尾空段落或合法引用前后的分隔空格会让有内容的消息报错;编辑器为了保持结构产生的空白文本片段被误判为“聊天内容为空”。
  • 原因:校验器对每个 input_text 单独执行 trim().is_empty() 并立即拒绝,混淆了结构化片段合法性和整条消息是否有实际内容。
  • 处理(现行口径):input_text 允许空字符串、空格和换行,校验过程保持全部片段的原文、分段与顺序,不做合并或删除;遍历完整条消息后,只在既没有非空白文字、也没有任意非文本 part(素材引用 / 运行画面引用 / Skill 引用 / 附件引用)时返回“聊天内容不能为空”。各类引用仍逐个执行原有校验,消息带正文也不能绕过非法引用。
  • 关联:apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs、docs/【功能说明】AGC聊天素材引用-2026-09-08.md。

2026-09-21 应用日志整行凭据脱敏会吃掉整条结构化诊断

append_application_log_line 在落盘前对整行做 sanitize_diagnostic_message:行内只要出现 token=、bearer 、authorization、credential、api key / apikey / api_key 这类标记,整行就被换成 <sensitive diagnostic details redacted>,只留下时间戳与 RUST module: 前缀;同时每行还会被截到 2048 字符。于是把“身份字段 + 诊断正文”拼成一行 app_log! 时,正文里一个凭据词就可能让整条记录连 eventId、code 一起消失(2026-09-21 加统一错误事件的日志投影时按两行落:身份行只放程序生成与调用方常量字段,summary / hint / detail 等自由文本一律只放详情行,且自由文本先自行压平换行——裸词标记脱敏消不掉,自由文本放错行会把 eventId、code 一起带走)。

2026-09-24 DirectProject 失败说明显示在用户消息之上、下一条消息看起来"没报错"

  • 现象:连发几条消息,每条都在连接阶段失败(执行器版本未通过验收)时,界面上"错误出现在自己消息的上面",上一轮底下显示"本轮结束于 <本轮结束时刻> · 耗时 15.6秒",自己这条底下显示"耗时 0.0秒";再发一条,失败说明落进更早的分区,用户以为这条没有报错。
  • 原因:① 本轮的开口用户条目(item_completed)原来在 app-server turn/start 应答之后才下发,连接阶段失败走不到那一步 → 事件流里只有逻辑回合的一对事件,没有开口条目;② 前端 buildDirectChatTurns 按条目顺序分回合,失败说明(assistant 条目)只能挂在"当前回合"(上一轮)末尾;③ 本地乐观气泡被排在所有正式条目之后,于是自成一轮(无边界 → Math.max(endedAt, startedAt) 兜底出 0.0 秒),上一轮则借用了本轮的终点(15.6 秒)。另一条独立漏洞:reducer 的收口早退(!turnRunning && live 为空)会整条吞掉"订阅重建只回放生命周期锚点"时那条失败说明。
  • 处理(现行口径):开口用户条目的发点提前到"接单 + 落盘成功、起 codex 之前"(emit_direct_thread_user_item),线上仍只有一处下发;回合归属改成按身份(失败说明带 turnUserItemId,同一身份的条目永远同一轮),本地气泡按身份挂回自己的回合;收口早退改为"说明还没写进界面就不早退"(只补说明与终点,不重开回合、不抬高冻结终点)。
  • 排查提示:先分清两层 —— 逻辑回合的 turn.started / turn.completed(Thread Manager,一定有、成对)vs app-server 协议的 turn/start 请求(连接拿到之后才发)。"失败说明挂错回合"永远先看这条顺序,不要先怀疑事件丢了。
  • 验证:宿主 the_opening_user_item_is_emitted_before_anything_that_can_fail_in_the_turn、前端 本轮用户条目没到时,失败说明按身份挂回自己那一轮,本地气泡不再自成假回合 与 收口早退不吞掉还没写进界面的失败说明(订阅重建只回放生命周期锚点)。
  • 关联:apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs、.../agent/direct_runtime/user_input.rs、.../chat/conversation/{directThreadChat.ts,directTurnPresentation.ts}、docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md。

Linux command.exec 的 leader 回收与后代退出存在时序差

  • bwrap 主进程已经 wait 回收时,namespace 后代仍可能短暂处于退出过程;一次 /proc 扫描发现活成员后再读取 leader 身份,会把正常退出误报为需人工核对。容器 PID 1 未回收的 Z / X 成员也不能当作活进程。
  • 正常退出与取消/超时共用有界的组退出确认,组内无活成员立即返回;缺失或变化的 leader 身份不能授权补发信号,持续活成员或读取失败仍报错。启动身份在 ready 后、commit 前记录;terminal 协议错误也不能跳过清理与 reader 取消。
  • 回归使用独立 subreaper 夹具:先 poll 清理并确认仍在等待,再让同组后代退出,覆盖正常收尾和取消/超时的共同清理路径;保持现有 CI 分片与并行,不靠取消并行或失败重试消除竞态。

2026-09-28 DirectProject 空历史注入被新版 app-server 拒绝:新项目第一条消息直接「执行通道中断」

  • 现象:新项目首条消息在请求 Provider 前失败,app-server 报 items must not be empty;正常宿主收尾也可能被误改为 Interrupted。
  • 原因与处理:空历史构造 items: []会被 app-server 拒绝,build_direct_project_history_injection_params 应返回 None 并跳过 thread/inject_items。另一路 shutdown 先标 closed 再关进程,在途 TransportClosed 仍可能在 session Working 时到达;fail_turn 须以宿主 closed 判定只追加终态说明,不改阶段。用户主动停止与真实通道故障仍按原终止口径处理。
  • 定位:GENARRATIVE_AGC_DIRECT_DEBUG=1 查看 shutdown 原因,区分主动收尾与外部断连;不要仅凭 TransportClosed 判失败。
  • 关联:apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_history_wire.rs、apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs。

2026-10-01 cc/Anthropic 必须走平台网关,客户端不得直连 Router

  • 现象:目录里把 claude-opus-5-5 标成 agentMode=cc 后,选它发消息必然失败:先是 Claude CLI 自己打印 Not logged in · Please run <path>,后来卡满 requestTimeoutMs=180000。
  • 根因:claude_code_cli.rs 用配置里残留的 llm.baseUrl(https://router.genarrative.world/v1)当 ANTHROPIC_BASE_URL,再把平台会话 token 塞成 ANTHROPIC_AUTH_TOKEN。平台凭据边界是「客户端只出示平台 access token,账号的 Router key 由 api-server 解析、绝不下发」(见 api-server/src/llm/mod.rs)。实测 POST https://router.genarrative.world/v1/messages 用平台 token 返回 401 Invalid token (new_api_error)。
  • 现行口径:baseUrl 不带路由与版本段,统一由协议自己拼 v1/<op>(服务端 router_protocol_url 会先把历史凭据末尾的 /v1 归一化掉)。平台侧 Anthropic 是独立路由 /api/llm/anthropic/{*path},客户端 ANTHROPIC_BASE_URL 设成 {apiBaseUrl}/api/llm/anthropic;OpenAI 侧是 /api/llm/v1/responses 与 /api/llm/v1/chat/completions,旧的无 v1 路径保留为已发布客户端的兼容别名。
  • 注意:Claude Agent SDK 固定请求 {ANTHROPIC_BASE_URL}/v1/messages?beta=true(外加一次 HEAD /api/hello 探测),网关不要自己再补 v1,用通配段承接客户端协议路径。
  • 后台协议选项:AgcAgentMode 增加 anthropic(显式 Anthropic 协议),cc 保留为同一执行器的历史别名;新增目录项应直接写 anthropic。

2026-10-02 AGC 的 cc 路由被本机 ANTHROPIC_* 环境顶掉,官方模型全发到用户个人中转

  • 原因:继承 ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN 可把官方模型请求发到用户个人中转;关闭 SDK settingSources 不会清掉进程环境。
  • 处理:cc 路由与凭据只由 AGC 平台会话或显式自定义配置决定,本机 ANTHROPIC_* 不参与;未登录且无 Key 时显式报错。sidecar 的 HOME、USERPROFILE 与 CLAUDE_CONFIG_DIR 都指向隔离目录,Windows 只改 HOME 仍可能读写真实 profile。
  • 超时:requestTimeoutMs 是连续无事件的静默预算,有事件就重置;45 分钟硬上限约束假活事件流。排查路由记录安全 source/host/auth 分类,不能写路径或凭据;诊断字段名也要避免触发整行脱敏而丢掉有效证据。
  • 关联:agent/claude_code_cli.rs、agent-sidecar/src/index.mjs;AGC 模型别名与对话选择。

2026-10-03 cc 回合"模型已回复却报宿主任务提前结束":缺终态 + 缺落盘 + 工具被拒

  • 原因:取得模型回复不等于宿主已完成回合。缺终态会被 guard 收成 HostDropped;只落历史不发 item.completed,本轮 UI 仍无回复;内部 MCP 用外部模式会缺 Direct turn 授权。
  • 处理:成功出口幂等写 completed,回复按实际落盘 item id 发 ThreadEvent::item_completed。同 client turn 的反馈回复内容相同复用 ID,不同追加唯一后缀,落盘失败保留真实失败。sidecar 工具放行与宿主权限同时落实;内部桥持有 begin_user_turn() 且为 Direct 模式,用户启动的外部桥保持自身来源合同。
  • 并发与错误:MCP 注册按 canonical 项目根分桶,stop 核对 server token,迟到旧回合不能误停新桥。cc 错误映射到现有 typed LlmError,不能全折为 Transport;HostDropped 与 panic 分别诊断,字段名遵守脱敏规则。
  • 核验:同时证明终态、project.jsonl、实时 assistant item、实际工具调用与不同项目隔离;一项成功不能代替其余出口。
  • 关联:agent/thread_manager/dispatch.rs、agent/claude_code_cli.rs、agent-sidecar/src/index.mjs。

2026-10-07 cc 会话恢复只活在进程内存里,「离开项目再进入」就看不到历史对话

  • 现象:离开项目或重启后 UI 历史仍在,cc 模型却不知道前文;project.jsonl 是 UI 事实源,不等于 SDK 会话恢复。
  • 原因:CLAUDE_DIRECT_SESSIONS 只在进程内,失败回合也可能未写入;SDK 轨迹另存于项目隔离 home 的 claude/projects/<sanitized-cwd>/<sessionId>.jsonl。
  • 处理:按“进程会话表→本项目目录 mtime 最新轨迹”查找。sanitized-cwd 遵循 SDK 非字母数字替换为-的规则,只认当前项目目录;跨目录捞 ID 会报 No conversation found。通过 agent.direct_codex.claude_resume source=memory|disk|none 区分内存、磁盘与确无历史。
  • 关联:apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs。

2026-10-07 cc 内置工具不该被关掉:ToolSearch 空转的真正解法是让自带工具可用

  • 现象:模型反复调用 ToolSearch,回包 No such tool available 后耗尽 maxTurns,整轮空结果。
  • 原因:sidecar 的 tools: [] 与 disallowedTools 关闭全部内置工具,仅留宿主 MCP;模型仍可能调用 SDK 原生工具,prompt 或重复失败后杀回合不能修复目录与执行不一致。
  • 处理:内置工具保持可用,去掉 tools/allowedTools/disallowedTools 限制,非交互 sidecar 使用 bypassPermissions 与 allowDangerouslySkipPermissions。看 system/init 的工具数与 MCP 状态确认真实工具面;使用 cc 还须有可接受原生 Anthropic 多工具请求的路由;协议桥接与它带来的 400 已随该层删除。
  • 关联:apps/ai-game-creator-shell/agent-sidecar/src/index.mjs、apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs;模型执行器合同。

2026-10-07 cc 模型说"我没有任何可调用的工具":先分清没连上还是模型说错

  • 现象:安装版 0.1.230,新建项目 gameagent-63d3fda7 的第一个回合(19:25),模型回答"在当前对话里,我没有任何可调用的工具…我能看到的上下文只有工作目录/平台/不是 git 仓库";同一版本 19:22 在 gameagent-0514673b 的回合里却真实调用了 mcp__agc__client_session_info 与 mcp__agc__conversation_list 并拿到回执。同一个构建既能用工具、又会被模型说成没有工具,所以"模型自述"不能当证据。
  • 受控复现(本地 SDK + 桩 MCP + 抓包桩):把随包 sidecar 指向桩 MCP 与桩 Anthropic 端点,抓到的模型请求里 tools=["mcp__probe__echo_probe"];把 MCP 握手延迟 12 秒、把 SSE GET 打成 405,请求里依然带着这个工具。也就是说 mcpServers / allowedTools / Authorization 这套接线本身是通的,工具缺失只可能来自真实链路里的连接失败——而宿主当时没有任何记录能证明 init 里工具到底有没有下发。
  • 现行口径:ClaudeCodeStreamState.observe 现在解析 SDK 的 system/init,并把工具数与我方 loopback MCP(agc)的状态写进应用日志 agent.direct_codex.claude_init tools=<n> mcp=agc:<status>。当 init 明确给出 failed / error / unavailable / disconnected / 服务器缺失 / connected 却没有工具,且本轮一次工具都没请求过时,这一轮不再把模型回复当成功交付,而是判成可重放的 McpUnavailable:先落一条可见过程行(客户端 MCP 工具未就绪,正在自动重试(第 N/3 次)),按既有上限自动重试,用满次数后回客户端 MCP 工具未就绪(agc=…):本轮模型看不到项目工具。pending / connecting / 看不到状态只留证据不判死(SDK 若改成非阻塞连接,不能把每一轮都判死);真调用过工具的一轮也不翻案。
  • 验证:cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute --bin genarrative-ai-game-creator-shell claude_ 38 passed(新增 claude_init_reports_whether_the_loopback_mcp_tools_are_actually_there,覆盖 connected / failed / missing / connected-无工具 / pending / 无 mcp_servers 六种 init 事实)。
  • 关联:apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs。

cc 自动重试必须区分静默失败与已请求工具

  • 原因:CLI/SDK watchdog 不保证在网关完全静默时发出可重试错误;宿主需要自己的预算,但回合已请求工具时重跑可能重复付费或其它副作用。
  • 当前:宿主最多 3 次尝试,静默超时只在本次无任何工具请求时自动重放;45 分钟硬上限不重放。当前实现还对可重试的 MCP 未就绪单独处理,以 claude_direct_failure_action 为准。
  • 处理:每次重试留下安全 system 过程行,复用 resume 历史;耗尽、工具已请求与不可重放分别反馈。attempts 进入结构化错误,内部标记不落入用户详情。
  • 核验:分别验证无工具静默超时、已有 tool_use、硬上限、MCP 未就绪与尝试耗尽;不能只证明手动重试成功。
  • 关联:agent/claude_code_cli.rs。

素材与资源管理

2026-10-03 AGC 栏目画布上传素材落「待归类」:kind 派生分类不等于入口栏目

  • 现象(Issue 359):在 AGC 资源栏目子画布(如「UI 交互」「角色与对象」)左下角工具栏点「上传」选图片 / 视频 / 代码类文件,提示条给出「已上传 1 个素材」,但当前栏目计数纹丝不动(仍「0 项」),素材出现在「待归类」。用户看到的是"上传成功了,可它就消失在这个页面里"。
  • 原因:上传登记的 manifest kind 只由内容证据推导(assets.rs::uploaded_asset_kind:图片 / 视频 / 代码 → unclassified,音频 → audio,文档 / 字体 → document),kind 派生分类与栏目词汇(ui-interaction / character / scene / audio)不是同一套;upload_local_asset 原先不接受入口栏目,GUI 工具栏上传只能落 kind 派生分类。
  • 处理(现行口径):upload_local_asset 增加可选 targetCategory,Rust 走既有的 register_local_asset_entry_with_category(非法值失败关闭,不回退 kind 派生);栏目画布工具栏上传时取工具栏自己的栏目(resourceCanvasBottomToolbarCategory)。生成入口 start_local_project_asset_generation 的 targetCategory 是同一口径——GUI 完成登记以入口栏目为准。
  • 判据/取证:cargo test --locked ... --bin genarrative-ai-game-creator-shell upload_ 的 assets::tests::upload_registers_into_the_explicit_entry_category(显式栏目 → character;不传 → unclassified;非法 version 失败关闭且不新增登记);appSurface「uploads toolbar files into the entry column so they stay visible where they were uploaded」断言工具栏上传载荷带 targetCategory: 'character'。
  • 边界:资源面板(跨栏目列表)、UI 编辑器图片导入、聊天附件上传都不带入口栏目,保持 kind 派生。任何新增的「某个栏目里的上传入口」都必须显式带上当前栏目,否则又会复现本坑。

2026-10-01 AGC 账户总量门槛会阻断单项查询与导入

  • 账户素材库接口当前返回全量快照;客户端不能以全库条数代替单次查询、返回或导入的资源边界。原先在解析阶段拒绝超过 500 项,会让 limit=1 和指定 ID 导入一起失效,且检查发生在下载和 JSON 解析之后,不能保护这两步开销。
  • 账户查询允许偏移超过 500,仍按单页最多 100 项返回安全元数据。修改时同步 MCP schema/校验、Direct 工具桥、Runtime 执行层及 agent_native_tools.rs 下发的 strict 原生函数 schema;仅直接调用 observation 会绕过模型侧参数约束,无法证明后续页可达,必须验证实际生成的函数参数 schema。共享分页函数的项目文件调用方仍保留自己的范围,画布资源数量边界独立维护。
  • 实际平台会话导入还需避免在身份租约内再次调用会话读取/校验:租约持有非重入互斥锁,提交 helper 重复校验会等待自身释放。下载后校验并取得租约,持有至本地提交完成即可;不能用无平台会话的提交夹具替代完整导入链路验证。
  • 后端有界分页与按 ID 读取由 #574 跟进,不作为 #549 客户端恢复可用的前置条件。当前合同与验证入口见 AGC 实施计划 的“账户 / 项目画布 / 本地素材导入”。

美术包固定 PNG 路径与平台返回格式不一致

  • 平台生成成功仍可能返回 JPEG/WebP;直接按 assets/art-spec.png 等固定路径保存会在扩展名校验时失败,图片尚未落盘但已付费结果账本仍存在,不能据 manifest 为空认定没有生成结果。
  • 美术包专用入口在本地提交前有界解码并归一化为 PNG,原 PNG 字节不变;扩展名、MIME、内容摘要和恢复比较必须使用同一份最终字节。普通图片工具和独立切片的格式合同不随之放宽,转码也不能代替真实透明度校验。
  • 失败后保留原账本并按冻结意图恢复,不能删除账本后重新生成。详情见 AGC 美术包合同。

客户端图标素材描述不能先包装再截断

  • 现象:资源画布填写了具体图标需求,平台实际收到的 prompt 却只包含泛化的小游戏美术指令,生成结果不遵循输入。
  • 原因:客户端先给用户描述套“Web 小游戏首版原型、核心美术素材”和默认 brief 模板,再把包装后的文本压入单项 200 字符;固定前缀已占满额度,用户描述在发给 API Server 之前就被丢弃。服务端校验只能看到被截短的文本,无法恢复原需求。
  • 处理:客户端画布图标生成只 trim 描述并以 prompt 字符串原样提交,保留内部换行;面板与原生入口按 1000 个 Unicode 码点校验并拒绝空白或超限输入,不静默截断、不机械拆条。服务端共用的提示词流程负责规范图、背景与排布要求;其它图片生成的 32000 字符上限保持不变。
  • 验证:检查实际请求体与 trim 后的原文一致,并覆盖 1000/1001 码点、补充平面字符、换行和空白输入;不能只断言“请求长度未超限”。完整合同见 画板图标素材生成入口设计。

同一条链路两处上限不一致:平台合法产出被客户端整条丢弃

  • 现象:客户端报「生成素材失败:platform-generation-result-unknown: 异步生成完成结果无法绑定到 operationId:External Editor 旧同步结果的图集切片超过 64 个」,而平台侧这次生成其实已经成功并切完图(任务账本耗时正常、assetId 为空、没有任何素材落盘,付费产物被丢)。
  • 成因:图集切片上限在链路里存在两份字面量——平台切分与持久化产物批次都是 256,客户端结果绑定门写着 64(agent/generation/{canvas_generation.rs,external_generation_state.rs})。自动切分(connected-components 且省略数量参数)切出 65~256 片是合法产出,客户端比平台更严就会把结果整条判失败。
  • 处理:客户端门统一到 PLATFORM_ART_SPRITESHEET_MAX_SLICES = 256,判据与文案各只留一份(数字由常量插值),并在注释里点名三处同值权威(平台切分常量、工具 schema、公开契约)。
  • 复用判据:凡是「平台产出 → 客户端校验后落盘」的链路,客户端门只能表达安全 / 预算约束,不得比平台的产品上限更严;两边上限要引同一个常量或同一份文档,改一边时必须同时改另一边,并补一条「上限之内必须能落盘」的回归用例。

远端资源编辑终态必须指出唯一出口

  • 现象:「生成背景音乐」再次提交 0.1 秒就失败,卡片只有 remote-terminal-failed: 远端资源编辑已明确失败,不允许再次请求,既没有原因也没有下一步。
  • 原因:上一次同 operationId 的请求被平台确定性拒绝(HTTP 400 或任务 failed)后,账本落到 remote-failed,之后所有重试都在 ensure_resource_edit_phase_resumable 失败关闭;唯一出口是「待恢复资源编辑」里的移出恢复队列,但终态文案没有指向它。
  • 处理:终态文案带出稳定失败码,并明确「先在待恢复资源编辑中把它移出恢复队列」;上游失败原文仍不写入账本(只存分类码),首次失败的原始拒绝说明继续由当次错误文案承担——轮询终态这条路由此改成把平台 error 原文装进 ResourceEditError::RemoteGenerationFailed 原样带出(2026-10-02),工具层经 RemoteResourceEditFailure 转发时保留 error 与 phaseDetail 两个原始字段(phaseDetail 只进诊断,不当用户文案);to_user_msg 只给 error 原文,平台没给就说「服务器未返回错误信息」。前缀由使用它的工具/命令自己加(不再统一压成「资源编辑生成失败」一句,也不再多一层无信息前缀);第一句失败文案不再带 remote-terminal-failed: 前缀。
  • 验证:remote_failed_status_is_terminal_and_can_only_be_archived 断言失败文案带出平台 error 原文、同时账本序列化不含原文;background_removal_remote_failure_keeps_manifest_without_result 覆盖平台没给 error 时的兜底文案;submission_bad_request_is_terminal_while_gateway_failure_requires_reconciliation 等资源编辑用例继续通过。
  • 关联:apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs。

AGC 自动同步必须绑定真实项目生命周期

  • 正式客户端在单窗口中用 React 状态打开/切换工程,窗口 URL 不代表当前工程。原生后台同步应读取由当前窗口显式登记的活动工程;首次打开、离开、切换、关窗及退出等待分别验证,不能只用携带 projectPath 的独立测试窗口证明正式入口可用。
  • 增量文件没有变化不等于远端清单没有变化。项目名和完整性元数据也参与提交判据,避免临时跳过恢复后永久停留在 partial,或新出现超限文件后仍显示 ready。历史清单缺少完整性字段属于未知,不能默认成完整。
  • ZIP 导出按一次冻结清单恢复相对路径并逐文件核验;直接下载内容寻址的 OSS 目录不能得到可用工程。源码/素材归档不包含依赖缓存、凭据和 AGC 对话运行状态。

2026-09-19 资源 kind 词汇收敛后,前端判据与 fixture 必须一起按 canonical 成员重写

  • 现象:工具栏入口的 assetKind 换成共享 GameCreationAppAssetKind(图集从平台词 art-spritesheet 改成 icon-spritesheet)后,「图集不接受用户参考」的判据仍写在旧的 ['art-spritesheet'] 字符串清单里,判据恒假:生成面板重新给图集渲染参考图选择器,原生提交再按合同显式拒绝多余参考。
  • 同类第二处:把 hasRegisteredArtImageAssets 从 kind === 'art-spritesheet' 直接换成「kind ∈ 视觉族」会把候选 UI 原型图(ui-design)算成美术图片产出,提前顶掉「美术资源计划已完成,尚未生成或登记图片」;判据必须显式排除 GAME_CREATION_APP_UI_DESIGN_ASSET_KIND。
  • 处理(现行口径):前端的资源 kind 判据只比较 canonical 成员或共享契约导出的谓词,不再维护第二份字符串清单;词汇收敛时先 grep 旧词在 src/ 与 tests/ 两侧的落点,fixture 同步按 canonical 成员重写。
  • 易错点:fixture 与生产代码写着同一个旧词时,单测会陪着一起变绿,用例证明不了任何事;另外 tests/appSurface.test.ts 不是全部同族用例,资源画布 / 画布生成参考等用例散在 tests/*.test.ts(x),词汇收敛必须连这些一起跑。

Windows 已登记生图资产未刷新

Direct 工具桥会 canonicalize 项目根,事件中的路径可能带 \\?\ / \\?\UNC\,而前端项目路径仍是普通盘符或 UNC。失效监听不能直接比较原始字符串;识别为同一项目后,用当前项目路径重读 manifest,保留项目切换与 revision 门禁。普通 agc_generate_image 成功提交也必须发出失效通知,不能依赖整轮 Agent 结束。回归需覆盖两种 Windows 前缀、其它项目事件拒收,以及 Agent 尚未结束和后续失败时已登记图片卡片仍可见。

预览读取不能像放弃旧 CAS 回调一样直接重置并发槽

  • 现象与原因:切项目或预览 scope 后重置计数,会让旧读取仍在占内存/CPU 时,新请求继续取得并发槽;前端 epoch 只隔离迟到结果,不能取消已经启动的 Rust blocking 解码。
  • 处理:旧 scope 取消与结果失效后,仍运行的物理任务继续占全局预算。permit/终态 guard 必须由 blocking closure 持有到真正结束;放在可被 abort 的外层 Future 会提前释放。全局至多 3 个物理读取,不能按窗口或 scope 各开一份。
  • 边界:取消过的 scope 必须留有界墓碑,过期 request 不得重新登记或复活;request 与 scope 的登记必须受预算约束。诊断应比较真实在途任务与逻辑队列,而非只看 React loading 数。

预览 Hook 的资源入参收窄会连带收窄 identity map,进而让总览其它栏目不画卡片(2026-09-11)

  • 现象:总览中只有当前分页栏目挂载真实资源卡,其余栏目只剩标题和占位。
  • 原因:useProjectResourceCardPreviews.resources 同时决定预热范围和 identityByResourceId;渲染方在身份缺失时返回 null。将它收窄到当前栏目,会连带丢失其它栏目的渲染身份。
  • 处理:resources 保持全量资源投影;eagerResources 只控制热预取,缺省回退到 resources。优化调度前先检查入参是否还参与身份或缓存键,不用无身份占位掩盖真实卡片缺失。
  • 验证:多个非空栏目同时存在时,总览各栏目都应挂载属于该栏目的真实卡片,并覆盖 eagerResources 缺省行为。
  • 关联:apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts、apps/ai-game-creator-shell/src/view/project-development/index.tsx、apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts、docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md。

资源分类字段必须按 manifest、投影与媒体读取三层契约使用

  • 现象:manifest 写了分类却全落入“待归类”,或部分图片正常、GIF / SVG / 视频 / 音频只显示占位。两类问题都可能在单测全绿时出现,因为 fixture 或 mock 与错误实现一起改了。
  • 原因:字段名相近但语义不同。manifest 的 GameCreationAppAssetManifestEntry.category 是落盘资产分类;前端 ProjectResource.assetCategory 才是投影字段,往 manifest 写 assetCategory 会被静默忽略。资源画本栏目也不是 native 媒体读取类别:read_local_project_media_preview 的 category 只接受 art / audio,透传 job.resource.category 的功能栏目值会被拒绝;PNG / JPEG / WEBP 走没有该参数的图片读取命令,因此仍能显示。
  • 处理:构造 manifest 或夹具先查共享契约,分类写 category,同时确保 kind 为 canonical 成员;媒体命令通过 projectResourceMediaPreviewCategory(resource) 从 projectResourceCardPreviewKind 派生 art / audio,不从栏目字段透传。新增或迁移字段时确认所在层及权威读取方,设置无效却不报错时先排查字段和层级。
  • 验证:apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts 的 mounts a real card body in every non-empty resource section overview 用真实 manifest 字段让五个栏目同时非空;useProjectResourceCardPreviews.test.ts 断言扩展名图片发 art、音频在播放意图后发 audio,appSurface 另覆盖 UI 交互栏目中的 icon.svg 仍发 art。mock 必须校验 native 真实取值,不能只按路径返回;将调用改回栏目透传时应失败。
  • 关联:packages/shared/src/contracts/gameCreationApp.ts、apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts、resourceCardPreviewModel.ts、useProjectResourceCardPreviews.ts、apps/ai-game-creator-shell/src-tauri/src/commands.rs。

External Editor taskId 不能当作本地 manifest taskId

  • 现象:Rust read model、资源详情或 dependency 聚类中缺少本应存在的 task-flow;测试用 design-foundation 之类字符串时正常,真实生成返回 task-1 后失败。画布不显示灰色 task-flow 是当前产品决定,不能再用是否出现虚线判断 producer 映射是否正确。
  • 原因:GameCreationAppAssetSource.taskId 保存的是 External Editor 生成任务身份,命名空间与本地 .agent/manifest.json 的 Agent/task 身份不同;前端用 taskById.get(source.taskId) 会让真实画布资产全部失去 producer。
  • 处理:资源依赖图的 Tauri Rust read model 从有界 .agent/agent.db 读取 agent.runtime.canvas.asset_generate,以 assetId -> agentId 映射 producer,并要求 agentId 存在于当前 manifest。记录缺失、多个不同有效 Agent 冲突或读取已截断时失败关闭 producer assignment、task flow 与对应 cyclicTaskIds,不回退 source.taskId。精确 asset-reference 仍只依赖 manifest 中外部 resourceId 的唯一匹配;Rust 独立返回的 dependencyDepths 继续作为 manifest / reference read model 权威结果,前端只过滤未知资源、负数、非整数和非安全整数,不得因 producer 截断把它整体清空。
  • 验证:Rust fixture 把 source.taskId 固定为 task-1 / task-2,只有审计提供 art-director / design-foundation 后才生成 task-flow read model;移除或截断审计后该 flow 消失但橙色引用保留,合法深度仍为 asset:spec=0 / asset:ui=1。前端始终断言 task-flow 零 SVG;AppSurface 使用截断生产数据形状证明深度 0 / 1 / 2 真实到达卡片布局,并且不会把已有自动坐标持久化成扁平布局。
  • 关联:apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs、apps/ai-game-creator-shell/src/view/project-development/resourceDependencyGraphModel.ts、docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md。

图集切片上限必须早于合并、裁剪和编码

  • 现象与原因:图集切片超限若在合并、裁剪、编码后才检查,仍会先耗尽 CPU/内存;大量噪声连通域的全量两两合并是 O(n²),最终切片数很少也不能证明成本有界。
  • 处理:先限制输入像素、连通域候选和输出数量,再进入合并/裁剪/编码;辅助区域只与邻近主体合并,不生成全量配对。读取、CPU 槽、完整批次和编码分别受预算/截止时间约束,超限返回可辨识错误。
  • 边界:本地切片先验证并准备整批媒体与身份,再一次原子提交资源;不能靠逐张安装后回滚来模拟批次事务。

Alpha 恢复失败后不能继续持久化原始后处理图

  • 现象:BgFilter 返回比例漂移、损坏或低分辨率图片,provider 原图修复性回读又失败时,图标 / UI 仍可能落库透明图与切片,尺寸元数据甚至回退为 512×512。
  • 原因:Alpha helper 会同时返回原后处理字节和错误;角色调用方会 source-only 早退,图标 / UI 却只写日志后继续。相同尺寸快路径还只读图片 header,没有完整解码。
  • 处理:角色、图标、UI 共用 provider 原图 source-only helper;比例漂移超过 5%、原图回读、Alpha 回贴或透明图完整解码任一失败都立即返回原图、通用 warning、空切片和空 sliceWarning,禁止透明图 PUT、派生资源、拆分和透明 / 切片画布层。provider 原图尺寸必须完整解码取得,不得伪造兜底值。
  • 验证:覆盖错比例 Alpha、缺失 provider 原图、合法 PNG header 但截断正文;结构断言 source-only helper 不含任何透明持久化、切片或多图层完成调用。
  • 关联:server-rs/crates/api-server/src/editor_project.rs、docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md。

图片生成的 K 档不能靠回图后缩放实现

  • 现象:用户选择 2K 时占位框看起来是 2K,最终资源元数据也显示为 2K,但模型请求实际仍是固定 1K 或竖版回落尺寸;画面只是后端放大后的低分辨率结果。
  • 原因:前端占位尺寸、api-server 的模型尺寸映射和 VectorEngine provider 合法尺寸各自维护;同时通用交付恢复与角色去背景恢复会直接缩放整张回图,掩盖了上游请求尺寸错误。
  • 处理:model + imageSize + aspectRatio 必须先解析为 provider 可直接生成的真实尺寸,前端占位和后端请求使用同一矩阵。带新尺寸字段的用户生成不执行回图后交付放大;角色、图标和 UI 的去背景服务若降采样,只缩放 alpha 蒙版并应用回模型原始 K 档 RGB。
  • 验证:覆盖 nanobanana2 / gpt-image-2 的比例与 K 档尺寸矩阵、VectorEngine 最终请求体、普通图片 / 角色 / 图标 / UI 占位,以及低分辨率去背景结果只贡献 alpha、不贡献被放大的 RGB。
  • 关联:src/components/image-editor/ImageCanvasGenerationModel.ts、server-rs/crates/api-server/src/editor_project.rs、server-rs/crates/platform-image/src/vector_engine/request.rs。

图片画布素材库删除要匹配 sourceResourceId

  • 现象:素材库中删除了已经生成并进入素材库的资源,但画布上对应图层仍然存在,刷新后还可能从已保存布局里恢复。
  • 原因:生成素材进入账号级素材库时可能通过 editor_asset.sourceResourceId 指向原项目资源;如果前端素材库映射和级联删除只比较 sourceAssetId、assetObjectId、objectKey 或 src,就会漏掉只靠项目资源 ID 关联的历史 / 后端生成图层。
  • 处理:EditorAsset 必须保留 sourceResourceId;从素材库添加到画布时继续写入图层;删除素材时同时比较 layer.resourceId / layer.sourceResourceId 与 asset.sourceResourceId。
  • 验证:ImageCanvasEditorModel.test.ts 覆盖素材库 source resource 保留,useImageCanvasAssetCanvasBridge.test.tsx 覆盖资源 ID 级联清理,ImageCanvasEditorAssetsIntegration.test.tsx 覆盖删除后保存的新 layout 不再包含被删图层。
  • 关联:src/components/image-editor/ImageCanvasEditorModel.ts、src/components/image-editor/useImageCanvasAssetCanvasBridge.ts、src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx。

图片画布素材选择有效性不要绑定搜索与折叠可见性

  • 现象:批量选择多个素材后,搜索、折叠文件夹或展开文件夹会让已选数量下降、Shift 范围锚点丢失,后续批量下载或删除遗漏此前已选素材。
  • 原因:搜索结果和文件夹展开状态只描述当前 UI 可见范围,不描述素材是否仍然有效;用 visibleAssetIds reconcile 全局选择会把暂时隐藏误判为素材失效。
  • 处理:由唯一 useImageCanvasAssetSelection 持有选择集合、范围锚点、框选和全部选择 mutation;全局选择只按全部 selectableAssetIds 清理真正删除、上传未完成、上传失败或媒体地址无效的 ID。visibleAssetIds 只作为单项切换、Shift 可见区间和当前结果全选 / 取消全选的动作入参,批量下载与删除消费 hook 输出的完整 selectedAssets;删除中包含当前未显示选择时,必须明确展示全部数量和未显示数量并二次确认。
  • 验证:模型测试覆盖隐藏选择保留、可见范围增量和真正失效 ID 清理;图片画布素材集成测试覆盖搜索、折叠 / 展开后选中数量稳定及当前可见全选不影响隐藏选择。
  • 关联:src/components/image-editor/useImageCanvasAssetSelection.ts、src/components/image-editor/useImageCanvasAssetLibrary.ts、src/components/image-editor/ImageCanvasSidebarView.tsx、src/components/image-editor/ImageCanvasEditorView.tsx。

后台素材查询与精选审核缩略图不要在首次挂载时全量换签

  • 现象:后台“素材查询”或“精选审核”首批缩略图正常,继续向下滚动、读取更多或一次加载较多审核项后长期显示占位图;api-server journald 中已到达的 /admin/api/assets/read-url 可能全部是 200。
  • 原因:列表一次挂载 80 条私有素材时,每个缩略图同时换签,会在同秒突发请求。production Nginx 的 genarrative_admin_rps 为 30r/s burst=16,超出部分在进入 api-server 前已返回 429,因此仅查 api-server 日志会漏掉失败请求。
  • 处理:素材查询与精选审核共用缩略图和预览组件;缩略图使用 IntersectionObserver 在进入视口附近时再调用管理端换签;对 429 使用有上限的退避重试,并在条目卸载后停止更新状态和安排重试。无 objectKey 的绝对 OSS generated 地址先提取 legacy path 再换签。不得为单页突发放大 Nginx 通用管理端限流,也不得在单次限流失败后永久保留无图占位。
  • 验证:前端定向测试覆盖两页共用换签组件、首屏外的后续行进入可见区后才换签、“读取更多”追加行可继续显示缩略图、429 后有限重试恢复、卸载后不再重试、绝对 OSS 地址换签和点击缩略图打开媒体预览;真实浏览器滚动验收时同时核对 Nginx access/error log、api-server journald 和 Network 面板,不以单一日志面判定成功。
  • 关联:apps/admin-web/src/components/AdminEditorAssetMedia.tsx、apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx、apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.tsx 及对应测试。

陶泥儿精选重复先查同源同媒体画布副本

  • 现象:每次从项目素材中把同一个生成素材拖到画布上,陶泥儿精选 都多出一张看起来相同的素材。
  • 原因:素材拖入画布会为图层实例准备 editor_project_resource;如果该素材本来带 sourceResourceId 指向原始生成资源,而新资源仍按普通 generated 资源公开,精选就会把原件和每次拖拽产生的同源同媒体副本都展示出来。
  • 处理:创建项目资源时保留 source_resource_id,并在同项目已有同源同媒体资源时复用已有 resource;确需创建同源同媒体副本时默认 public_showcase_enabled = false。公开精选读取和前端精选模型都跳过 sourceResourceId 指回同一媒体原件的副本,但不要按图片地址全局去重,避免不同生成步骤共享占位图时被误合并。
  • 验证:creationShowcaseModel.test.ts 覆盖同源同媒体副本只展示原件;ImageCanvasEditorAssetsIntegration.test.tsx 覆盖拖拽生成素材到画布时继续提交 sourceResourceId;cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml 确认后端资源复用 / 精选过滤逻辑可编译。
  • 关联:server-rs/crates/spacetime-module/src/editor_project_storage.rs、src/components/creation-home/creationShowcaseModel.ts、src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx。

OSS 导出兜底必须覆盖响应体读取阶段

  • 现象:浏览器控制台显示 OSS 206 Partial Content 后紧跟 net::ERR_FAILED,画布预览或素材导出失败,但没有出现预期的 /api/assets/read-bytes 兜底请求。
  • 原因:fetch(signedUrl) 可能先返回一个 ok 的 Response,网络、浏览器 Range 缓存或传输错误随后才在 arrayBuffer() / blob() 消费响应体时暴露;如果直连保护边界只包住 fetch 和状态码,响应体失败会绕过 fallback。
  • 处理:私有素材字节读取必须在直连 OSS 分支内完整消费响应体并重新构造可重复读取的 Response;换签、请求、非成功状态或响应体读取任一阶段失败时统一回退同源 /api/assets/read-bytes。Abort 仍应直接上抛,不能转化为额外服务器读取。
  • 验证:前端服务测试模拟 OSS 返回 206/ok、但 blob() reject,断言随后请求 /api/assets/read-bytes 并返回 fallback 完整字节;同时保留直连成功、局部分片、直连非成功、请求 reject 和 Abort 边界。
  • 关联:src/services/assetReadUrlService.ts、src/services/assetReadUrlService.test.ts、src/components/image-editor/ImageCanvasExportModel.ts。

图片画布音频卡播放条 0:00 要优先查签名 URL 和嵌套交互

  • 现象:画板音效或背景音乐已经生成成功,但卡片里的播放条显示 0:00,点击无法预览。
  • 原因:generated 音频资源通常是私有 OSS 路径,直接把 /generated-* 或 generated OSS 地址交给 <audio> 会无鉴权读取失败;如果音频控件嵌在 <button> 图层里,浏览器还可能因嵌套交互元素阻断 controls 行为。
  • 处理:音频图层使用非嵌套交互容器承接画布选择语义,内部 <audio controls preload="metadata"> 单独阻止 pointer / click 冒泡;generated 音频播放前统一通过 useResolvedAssetReadUrl / /api/assets/read-url 换签。卡片和角标展示 时长,后端没返回时长时可用 loadedmetadata.duration 兜底。
  • 验证:npx vitest run src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/ImageCanvasMetadataModalView.test.tsx src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts --reporter verbose,并在浏览器确认 generated 音频控件可播放。
  • 关联:src/components/image-editor/ImageCanvasWorldView.tsx、src/components/image-editor/ImageCanvasMediaModel.ts、docs/【编辑器】画板音乐生成入口设计-2026-06-18.md。

图片画布生成请求必须提交稳定媒体引用

  • 现象:参考生成、去背景、角色动画或 Seedance 视频提交 Data URL / Blob URL 后报“必须先上传 OSS”、413 或上游 URL 无效;请求体与持久队列 JSON 也会被媒体本体放大。
  • 原因:浏览器临时媒体不能作为持久任务的来源。signed URL 只用于展示;服务端签发私有对象 URL 前还必须校验当前 owner,不能直接信任客户端 objectKey。
  • 处理:允许 objectKey 的生成入口优先复用已有 objectKey、项目资源 ID 或素材 ID;本地图片和普通 public 图片路径先经 resolveEditorGenerationMediaReference(...) 上传,再提交稳定引用。角色动画同样执行该流程,后端入队前拒绝内联媒体,不靠放宽 body limit 兼容。Data URL 只留在浏览器压缩、标注等临时处理中,不进入 API、队列或项目持久化。图片快速编辑主来源更窄:只接受当前账号正式项目资源 ID / 素材 ID 的 sourceReferenceId,上传对象须先登记,见“图片编辑主来源不能接受 objectKey 或请求类型”。
  • Seedance 边界:本地参考图片 / 视频 / 音频先经 /api/assets/direct-upload-tickets 直传,再由 /api/assets/objects/confirm 确认;前端保留 signed URL 预览,提交正式引用。后端统一拒绝 data:* / blob:*,项目资源 ID、素材 ID、objectKey 先解析为当前 owner 的对象,公网 URL 与 asset:// 按供应商契约处理;引用字段总长度上限 256KB,非 Seedance 模型不得携带这些参考字段,Ark body 显式带 generate_audio:false。
  • 验证:前端定向覆盖 editorProjectClient.test.ts、useImageCanvasGenerationSubmissionWorkflow.test.tsx、useImageCanvasUploadWorkflow.test.tsx、ImageCanvasGenerationSubmissionModel.test.ts、editorReferenceUploadClient.test.ts;后端运行 cargo test -p api-server inline_data_url、editor_character_animation、editor_video 及 cargo test -p shared-contracts editor_video_request_supports_seedance_multimodal_references(均使用 --manifest-path server-rs/Cargo.toml)。夹具与文档示例也必须使用稳定引用,不能把 Data URL 作为合法输入。
  • 关联:src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts、src/services/image-editor/editorReferenceUploadClient.ts、server-rs/crates/api-server/src/editor_generation_queue.rs、server-rs/crates/api-server/src/character_animation_assets.rs。

图片编辑器角色动画抽帧不要采到视频尾点或逐帧重启 FFmpeg

  • 现象:画板角色图点击 生成动画 后,Ark 视频已生成并上传 OSS,但后端返回 ffmpeg 已执行但未产出动作帧文件(requestId:...)。
  • 原因:FFmpeg 在采样时间落到视频尾点附近时可能退出码仍为 0,但实际输出 0 帧;如果后端按 duration - 0.001 抽最后一帧,低帧率或短视频很容易踩到不可解码尾点。旧实现还会为 32 / 40 / 48 个采样点分别启动 FFmpeg、重复解码同一视频,在低配 worker 上形成不必要的多秒 CPU 尖刺。
  • 处理:角色动画先按目标帧数计算全部安全采样时刻,例如 32帧·4秒 最后一帧采 3.875s,不要采 3.999s;随后使用单个 setpts + split + select filter graph 批量输出全部帧,不改用粗粒度 fps 抽帧。命令返回后逐一检查输出,缺帧时用户主文案保持简短,details 保留首个缺帧的 targetSeconds / outputPath、整批 missingFrames 和 stdout/stderr。
  • 验证:cargo test -p api-server editor_character_animation --manifest-path server-rs/Cargo.toml;editor_character_animation_batch_extracts_all_samples_from_short_video 必须用一次 FFmpeg 产出整批短视频帧,尾帧测试继续锁定 3.875s。
  • 关联:server-rs/crates/api-server/src/character_animation_assets.rs、docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md。

图片编辑器项目和素材 payload 不能持久化内联媒体

  • 现象与原因:项目/素材/layout 快照出现 MB 级 Data URL 时,恢复变慢并可能 OOM/413;signed URL 写入正式数据又会在过期后失去预览。媒体本体与临时展示地址都不是持久化引用。
  • 处理:正式写入只使用已确认的 objectKey/assetObjectId 与轻量路径;layout 递归拒绝 data:/blob:。旧数据有 objectKey 就归一,无稳定引用的旧内联媒体先修复上传再回写。展示统一换签,session cache 按用户隔离且只有显示权,不能先于权威快照自动保存。
  • 竞态:认证重载时同步用 ref 关闭写门禁并清 revision、pending save、timer;不能等下一次 render,否则旧 effect 可消费 skip 标记并覆盖另一设备的新布局。expectedRevision 在 autosave effect、队列和真正发送前都必须有效。
  • 回包:未发资源请求按用户/项目隔离;发起时冻结权威快照序号。认证重载、409 恢复或生成完成已替换快照后,只合并响应对应图层,不能用历史 snapshotLayers 整体覆盖当前布局。

图片画布裁扩后刷新或去背景丢图先查项目资源化

  • 现象:从规范图裁切 / 裁扩出新图层后立即执行去除背景,去背景完成时原裁扩图从画布消失;刷新后裁扩图仍不在,但去背景占位可能变成结果图。
  • 原因:裁扩结果由浏览器 canvas 本地渲染为 data:image/png。如果项目态先把 local-resource-* 图层加入画布,serializeLayer 不会保存 src,resolveProjectResourceCreateImageSrc 又会跳过内联 Data URL,后续应用后端 project snapshot 或刷新 hydrate 时找不到对应 editor_project_resource,该源图层就会被过滤。去背景带 canvasCompletion 时后端只负责把结果写入生成占位,不会恢复这个未资源化的裁扩源层。
  • 处理:项目上下文中的裁扩结果必须在加入画布前先上传 OSS / asset object,再创建 editor_project_resource,并用服务端返回的 resourceId/objectKey/assetObjectId 创建裁扩图层;随后去背景的 sourceResourceId 和图片读取都指向正式资源。queue 模式下去背景完成后,如果首次读取的项目快照中对应 generation-dialog 仍是 generating 或缺少 generatedLayerId,前端短暂等待后再读取一次项目快照。
  • 验证:npm run test -- src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx -- --runInBand 应覆盖裁扩先上传再创建项目资源,以及去背景队列完成后对未完成占位进行二次项目读取。
  • 关联:src/components/image-editor/useImageCanvasGenerationWorkflow.ts、src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts、docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md。

图片画布不能用 local-* 前缀代替资源登记状态

  • 现象:刚加入画布、资源登记仍在途的图层被复制后,权威项目快照刷新与资源创建响应交错,副本可能刷新后消失;反过来,历史自包含角色动作序列虽然也使用 local-*,却会被永久禁用并持续提示“素材仍在保存”。
  • 原因:local-* 同时覆盖两种不同状态:新素材的临时 ID,以及没有项目资源行、但已凭完整持久化帧成为终态的历史兼容序列。ID 前缀不是状态机;把所有本地 ID 当 pending,或把多个布局调用压进一个临时 ID single-flight Promise,都无法表达每次请求的真实生命周期与恢复上下文。
  • 处理:新素材是否 pending 必须读资源登记在途集合;存在明确在途请求时,复制、剪切、创建副本和内部粘贴整体拒绝,正式 resourceId 回填后开放。历史序列按结构化持久化的严格自包含谓词识别,不能显示保存中;暂不支持复制时用准确原因失败关闭。既非 pending 又不满足历史谓词的 unresolved local 图层也拒绝,但提示“资源尚未登记”而非“仍在保存”。不要把普通 layout PATCH pending 当资源登记状态,也不要阻止系统剪贴板图片导入。资源创建不再按临时 ID single-flight 合并;布局保存的串行 latest-wins 队列仍保留。
  • 验证:ImageCanvasLayerCommandModel.test.ts、useImageCanvasLayerCommands.test.tsx 和 ImageCanvasContextMenusView.test.tsx 分别覆盖登记在途整体拒绝、正式 ID 回填后恢复、历史 self-contained local sequence 不误报保存中,以及 unsupported / unresolved 的准确提示;useImageCanvasProjectPersistence.test.tsx 继续覆盖单个资源响应与权威快照交错恢复。
  • 关联:src/components/image-editor/ImageCanvasLayerCommandModel.ts、src/components/image-editor/useImageCanvasLayerCommands.ts、src/components/image-editor/useImageCanvasProjectPersistence.ts、docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md。

图片画布项目封面上传失败要有本地展示兜底

  • 现象与辨识:本地封面 Blob 已生成,但项目列表仍无封面时,先区分 OSS/CORS 上传失败与采样失败;上传失败不会产生正式 project-cover-snapshot。
  • 处理:服务端封面仍是跨设备真相,IndexedDB Blob 只作当前浏览器展示兜底;不得进入项目快照、正式资源或替代 OSS。列表按正式封面→本地兜底→可见图层/占位读取。
  • 边界:封面是布局派生物,不为 ResizeObserver 单独增加保存。主动返回项目页先 flush 最新权威布局并等待对应封面写入;有 drawable 但当前取景全部离屏时写纯背景封面,不能沿用旧图。采样复用主画布换签缓存,确认后创建资源只用稳定对象引用。

图片画布框选预览要复用源图换签缓存

  • 现象:UI 设计素材提取或快速编辑框选时,画布上的红色框选还在,但底部“框选区域预览”卡片变成空白。
  • 原因:预览图从原生 img 改成 ResolvedAssetImage 后,如果没有传入源图同一套 objectKey / refreshKey,它会另起一条 /api/assets/read-url 缓存维度;画布主图已经显示时,预览仍可能处于空签名或失败缓存状态。
  • 处理:框选预览继续用 ResolvedAssetImage 承接私有资源换签,但必须传源图 objectKey,并使用 taskId ?? resourceId 作为 refreshKey,和主画布图片保持同一签名缓存版本。只允许对 data:、blob: 或已带签名参数的 URL 设置 fallbackSrc;不要把裸 /generated... 私有路径作为 fallback 写进 img。
  • 验证:npm run test -- src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.test.tsx --reporter=dot 应断言私有框选预览带 objectKey、refreshKey,且裸 generated 路径没有 fallback。
  • 关联:src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.tsx、src/components/ResolvedAssetImage.tsx、src/hooks/useResolvedAssetReadUrl.ts、src/services/assetReadUrlService.ts。

图片画布发布入口 429 也要查 read-url 换签爆发

  • 现象:发布域名刚上线或刷新画板后出现短时间 429,Nginx access log 中集中为同一 IP / 同一 editor/canvas?projectid=... referrer 的 GET /api/assets/read-url?objectKey=generated-character-drafts/editor/ui-design-assets/.../asset-001.png 到几十上百个 UI 设计切片;429 行常见 request_time=0.000、upstream_status=-,error log 写 limiting requests ... zone "genarrative_api_rps"。
  • 原因:这类 429 是入口 Nginx limit_req 在转发前按 RPS burst 快拒,不是 api-server、SpacetimeDB、worker 或 VectorEngine 的业务 429。UI 设计提取、角色动画帧或大量私有素材恢复会让多个 ResolvedAssetImage 同时挂载;如果 /api/assets/read-url 只有同 key pending 去重和缓存,没有跨 objectKey 节流,一个页面能在同一秒内发出数百个不同 objectKey 换签请求并打满 genarrative_api_rps burst。
  • 处理:不要先放大 Nginx 通用 API 限流;先按 access log 聚合 read-url 数量、状态和 referrer,确认是否同一画板页面触发。assetReadUrlService 必须统一承接私有 generated 资源换签,并在真实请求前做跨组件轻量节流;画板、素材库、运行态和结果页不得直接绕过该服务调用 /api/assets/read-url。
  • 验证:npm run test -- src/services/assetReadUrlService.test.ts --reporter verbose 应覆盖大量不同 objectKey 同时换签时首批限量放行、后续按间隔派发;发布现场同类页面刷新时,Nginx GET /api/assets/read-url 429 应从 upstream_status=- / genarrative_api_rps 收敛。
  • 关联:src/services/assetReadUrlService.ts、src/hooks/useResolvedAssetReadUrl.ts、src/components/ResolvedAssetImage.tsx、docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md。

图片画布纯尺寸变换必须先在内存决策再单次上传

  • 现象与原因:nanobanana2 已成功回图,却在后处理报“尺寸无效”,通常是把 512/1024/2K 标量当 WIDTHxHEIGHT 解析;同步内联回包的本地兜底 task ID 不能向 provider 回查。
  • 处理:nanobanana2 保留实际几何,其它模型按显式像素目标尝试恢复。普通生图/快速编辑在内存中完成尺寸决策:成功只上传变换图,失败只上传 provider 原图;每个主结果只做一次 OSS 持久化并创建一个素材。
  • 付费边界:扣费确认以 provider 成功为界,不能延长到 OSS、后处理或画布回填。角色、图标、UI 提取和动作按多产物合同保留可恢复原始产物;原始阶段承担模型成本,后续抠图/抽帧/切片成本为 0,使用真实素材类型。

资源卡预览每次启动重新加载不要改成 Tauri asset 协议(2026-09-11)

  • 现象:客户端重启后,本地资源卡预览需要重新加载;预览缓存只在进程内,素材源文件本身不是可交给 WebView 的 URL。
  • 原因:React 持有可撤销 Blob URL 缓存,Rust 读取管理器只登记读取任务、不缓存字节。data URL 只作 IPC 临时载体,进入 React 状态前转为 Blob URL。
  • 处理:分别测量文件读取、编码、IPC、JS 转换和图片解码耗时,再选择异步解码、可见栏目预取或经过身份复核的内存缓存;不能仅凭“重启重读”认定文件读取是瓶颈。
  • 边界:预览继续使用进程内、按 scope 隔离的有界缓存,不引入磁盘缩略图缓存或跨 scope 的项目级 Blob 缓存。不能为性能直接换成宽泛 asset: 文件访问,绕过项目权限、相对路径与登记校验、敏感路径、链接和文件身份、类型与尺寸限制。取消语义和并发预算也必须保留;不要把任意本机绝对路径暴露给 WebView。
  • 关联:apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts、apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts、apps/ai-game-creator-shell/src-tauri/src/{image_inspect.rs,resource_preview_scheduler.rs,commands.rs}、apps/ai-game-creator-shell/src-tauri/{tauri.conf.json,Cargo.toml}、docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md、docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md、docs/project-memory/shared-memory/decision-log.md。

generated 图片重复下载不要改成服务端本地磁盘缓存

  • 现象:同一张 OSS generated 图片每次展示都重新从 OSS 拉取,或者完整 OSS 私有 URL 裸请求返回 403。
  • 原因:前端输入如果是 https://*.oss-*.aliyuncs.com/generated-*,会被当普通绝对 URL 直连,绕过 /api/assets/read-url 和 signed URL 本地缓存;旧 OSS 对象如果缺少 Cache-Control,浏览器只能依赖 ETag / Last-Modified 做 304 协商缓存,不会长期强缓存。
  • 处理:完整 OSS generated URL 先归一成 /generated-* legacy public path,再走 /api/assets/read-url 换签;refreshKey 是 signed URL 缓存版本号,同一路径、同一版本且未临近过期时必须复用,不要每次渲染都强制重新换签。新上传 generated 私有对象由 platform-oss 在 PostObject form fields / policy 和服务端 PutObject 请求头中写入 Cache-Control: public, max-age=31536000, immutable。不要把 api-server 变成图片静态代理,也不要把 OSS 内容 fallback 到服务器磁盘。
  • 验证:前端测试应看到完整 OSS generated URL 调用 /api/assets/read-url?legacyPublicPath=...,且相同 refreshKey 不重复换签;cargo test -p platform-oss --manifest-path server-rs/Cargo.toml 应覆盖 Cache-Control policy、form field、PutObject headers 和 V4 AdditionalHeaders;线上旧对象可用 curl -I 观察是否只有 ETag / Last-Modified 或已经补齐 Cache-Control。
  • 关联:src/services/assetReadUrlService.ts、server-rs/crates/platform-oss/src/lib.rs、server-rs/crates/platform-oss/README.md、docs/【开发运维】本地开发验证与生产运维-2026-05-15.md。

图片画布 UI 提取素材切片不要把断开的高光阴影当独立图标

  • 现象:图片画布提取 UI 素材后,右侧素材库出现很小的废图;主体图标的阴影、反光、高光或小装饰不完整。
  • 原因:图标 spritesheet 切片按 alpha 连通域识别素材,模型常把软阴影、高光、小星星等画成与主体断开的透明块;如果直接逐连通域出图,小碎片会抢占图标顺序,主体也会缺边缘装饰。
  • 处理:在 platform-image 的 sheet.rs 里先合并靠近主体的辅助连通域,再过滤孤立小碎片,最后给裁剪框保留安全 padding。不要在前端素材卡或画布层里修已经切坏的 PNG。生成图标素材 入口只回填扣绿后的整张图集,不再拆分独立图标。
  • 验证:cargo test -p platform-image generated_asset_sheets --manifest-path server-rs/Cargo.toml 覆盖断开的高光合并和孤立小碎片过滤;调用方补跑 cargo test -p api-server editor_icon --manifest-path server-rs/Cargo.toml。
  • 关联:server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs、server-rs/crates/api-server/src/editor_project.rs。

UI spritesheet 不要依赖模型直接生成透明背景

  • 现象:拼图或抓大鹅运行态解析 UI spritesheet 时,把整张背景图、棋盘格、叶子或装饰图也当作 UI 素材区域,按钮映射错乱;截图里常表现为底部按钮区只剩透明棋盘格或素材碎片。
  • 原因:前端解析依赖 alpha 连通域检测,透明背景是前提;但生图模型收到“透明背景 spritesheet”提示后仍可能输出带实景背景或伪透明棋盘格的普通不透明 PNG,OSS 中保存的图没有真实 alpha。
  • 处理:UI spritesheet 提示词应要求统一单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕背景,而不是让模型直接产透明背景;后端在上传 OSS 前复用 generated_asset_sheets::apply_generated_asset_sheet_green_screen_alpha(...) 把绿幕扣成真实透明 PNG,再把透明图写入 uiSpritesheetImageSrc/uiSpritesheetImageObjectKey。
  • 验证:cargo test -p api-server puzzle_ui_spritesheet_postprocess_turns_green_screen_transparent --manifest-path server-rs\Cargo.toml、cargo test -p api-server puzzle_level_scene_spritesheet_and_background_requests_use_references --manifest-path server-rs\Cargo.toml、cargo test -p api-server match3d_derived_asset_prompts_match_three_sheet_pipeline --manifest-path server-rs\Cargo.toml。
  • 关联:server-rs/crates/api-server/src/puzzle/generation.rs、server-rs/crates/api-server/src/match3d/works.rs、server-rs/crates/api-server/src/generated_asset_sheets.rs、docs/【玩法创作】平台入口与玩法链路-2026-05-15.md。

generated 音频路径进运行态前要先换签

  • 现象:草稿页 audio 控件能播放背景音乐,但拼图或抓大鹅运行态开局后背景音乐不响,Network 可能出现裸 /generated-*-assets/...mp3 私有路径 403。
  • 原因:生成音乐转存到 OSS 私有对象后,audioSrc 是 generated legacy path;浏览器 <audio> 不能像公开静态资源一样直接请求裸路径。另一个常见误判是浏览器拒绝自动播放,资源已经进入运行态但开局第一次 audio.play() 被拦截。
  • 处理:结果页试听控件和运行态隐藏 <audio> 设置 src 前,都先通过 useResolvedAssetReadUrl 或 resolveAssetReadUrl 换签;签名未就绪时不要回退请求裸 generated 路径。运行态自动播放失败只静默兜底,但玩家首次按下拼图块或点击抓大鹅物品时要重试同一个背景音乐播放函数。拼图读取 currentLevel.backgroundMusic.audioSrc,抓大鹅读取 generatedItemAssets[].backgroundMusic.audioSrc。
  • 验证:结果页试听和运行态 <audio loop> 的 src 为签名 URL 或公开 URL;拼图/抓大鹅运行态首次局内交互后会再次尝试播放背景音乐;npm run typecheck 不报契约字段缺失,后端 run response 带 backgroundMusic。
  • 关联:src/components/puzzle-runtime/PuzzleRuntimeShell.tsx、src/components/match3d-runtime/Match3DRuntimeShell.tsx、docs/technical/PUZZLE_MATCH3D_RESULT_AUDIO_TAB_2026-05-11.md。

拼图生成完成后图片只显示破图或 alt 文案

  • 现象:拼图结果页生成完成后,“画面图”区域出现破图图标和作品名,图片无法正常预览;但打开历史拼图素材时同一张图可能可以正常预览。
  • 原因:拼图正式图保存为 /generated-puzzle-assets/* 兼容标识,旧 /generated-* 直读代理已删除;如果前端没有通过 ResolvedAssetImage / /api/assets/read-url 换签,或收到无前导斜杠的 generated-puzzle-assets/* object key 后未识别为 generated 私有资源,浏览器会直接请求裸路径并失败。生成完成后的结果图还会传入 refreshKey,它只能作为 signed URL 缓存版本号,不能给 OSS V4 签名 URL 追加 _v;OSS 会把 query 纳入签名,额外参数会让签名失效。
  • 处理:拼图结果页、发布预览、运行态和历史素材预览都走 ResolvedAssetImage 或 useResolvedAssetReadUrl;generated 私有资源识别必须同时覆盖 /generated-*、generated-* 和 https://*.oss-*.aliyuncs.com/generated-*;refreshKey 变化时重新换签,同一路径同一 refreshKey 且签名未临近过期时复用已返回的 OSS 签名 URL;禁止恢复 /generated-puzzle-assets 直读代理。
  • 验证:运行 npm run test -- src\services\assetReadUrlService.test.ts src\hooks\useResolvedAssetReadUrl.test.tsx src\components\puzzle-result\PuzzleResultView.test.tsx,再触发一次真实生成确认 Network 中先请求 /api/assets/read-url,图片 src 为未追加 _v 的签名 URL。
  • 关联:src/services/assetReadUrlService.ts、src/components/ResolvedAssetImage.tsx、docs/technical/PUZZLE_IMAGE_ASSET_PROXY_FIX_2026-04-27.md。

角色动作不能靠素材主图或通用生成输入恢复

  • 现象与原因:动作整画布导出正常、单素材却只得首帧或拖回后退成 PNG,应查帧集是否误放 generationInputs/layout。用户输入清洗、DTO 映射或布局恢复会丢运行结果;添加 mediaType 不能补正式资源事实。
  • 正式写入:完整帧集及毫秒时长只写 resource/asset 正式序列字段,数组位置是唯一帧序,帧数/FPS 按需派生;精选快照冻结相同字段。layout 只保存资源引用与 placement,读侧不添加旧 JSON fallback。
  • 回填身份:生成响应必须使用已持久化的最终 resource,禁止构造 local-resource-* 再创建重复资源。来源链为原角色→预览视频→最终序列,新层的 sourceResourceId 取最终资源的直接来源;修身份时不顺带改变占位显示尺寸。
  • 历史迁移:仅 migration operator 按 asset→project-resource→showcase→canvas 规范化。先由权威对象类型证明动作身份、排除同 task 预览 MP4,再接受唯一最终序列并逐帧补稳定对象引用;零/多候选、正式与旧结果冲突或规划 blocker 均失败关闭。后续 scope 可消费前序计划态类型,但 apply 要求前序物理完成;旧 layout 复制来源不作为血缘证据,清副本后采用 DB 直接来源。新 HTTP/storage 仍拒绝旧运行字段与 frameIndex,不把普通 JSON 同名字段误作动作。
  • 关联:角色动作正式字段与迁移合同。

精选角色动作显示首帧还要检查前端 renderer 与逐帧授权

  • 现象与原因:接口已有完整帧集但精选/后台仍只显示首帧,或首帧可读、后续帧 404,应分别检查正式快照、序列 renderer 和逐帧授权;顶层精选 grant 不自动覆盖其他帧。
  • 处理:assetKind=character-animation 映射序列 renderer,公开和后台共同消费正式帧集与毫秒时长。生成收口必须保留每帧 assetObjectId/objectKey;公开 grant 只从同一事务快照里有效精选动作的同 owner 帧精确派生,不能放宽 generated 前缀。
  • 边界:未交互列表只读首帧,激活后仅当前帧与有界预读窗口换签;坏帧可跳过,全帧失败暂停并显式重试。hover/focus 任一成立都保持播放,reduced-motion 默认暂停且允许手动播放。unit/component 结果不能表述为真实 E2E 验收。

可复用资源回填必须保持时间戳单调

  • 现象:延迟重试携带比既有行更旧的调用方时间,回填图片序列字段时若无条件写入,会使 updated_at 倒退,导致基于时间戳的同步看不到更新或排序错误。
  • 处理:同源图片序列字段只允许 None → Some,非空冲突失败关闭;发生回填时 updated_at = max(existing.updated_at, request_timestamp)。legacy 音频 repair 不派生资源级图片序列或通用时长,重放继续精确匹配。

跨窗口 CAS 锁不能用 mtime stale 删除模拟系统互斥(2026-07-30)

  • 现象:两个窗口基于同一 revision 保存资源布局时,正常测试看似只有一个成功;锁文件超过 stale 阈值或两个竞争者同时判断过期时,却可能各自删除 / 重建锁并同时进入 read-check-write,击穿“同 revision 最多一个成功”。无效绝对路径还会在 manifest 报错前遗留 .agent/workbench/resource-layouts。
  • 原因:create_new 只保证某一时刻创建文件原子,不保证“判断过期 → 删除 → 重建”整体原子;mtime 不能证明 owner 已退出,token 文本也不能阻止另一个竞争者删除新锁。先获取锁再读 manifest 又把目录创建副作用提前到了项目身份验证之前。
  • 处理:锁文件作为持久入口永不由应用删除;Unix 用文件描述符持有 flock(LOCK_EX | LOCK_NB),Windows 用 share_mode(0) 独占句柄,Drop / 进程退出让操作系统释放锁。安全打开逐级拒绝符号链接 / reparse point,Unix 还核对 owner、硬链接数、inode 和 0600。更新携带只用于校验的 expectedProjectId,先只读验证 manifest,再获取系统锁并在锁内复核 projectId;不存在根、非项目根、损坏 manifest 和路径复用后的旧窗口都不能创建 workbench。revision 必须 checked increment,耗尽时不能饱和成功。
  • 验证:必须覆盖活锁 mtime 被设为 epoch 后竞争者仍拿不到锁、释放后同一 inode 可重新获取、同 revision 并发双写仍恰好一个 updated / 一个 conflict,三类无效根和旧 projectId 零 workbench 副作用,以及 u64::MAX revision 保持原文件。锁等待超时只能返回可重试错误,不得转为 stale 删除。
  • 关联:apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs、docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md。

内部处理模型的可见性过滤是标题精确匹配,不是语义识别

  • 现象:读文档以为「内部处理模型不会展示给普通用户」是全覆盖保证,实际历史素材的图片信息弹窗和画布 ZIP 导出里仍能看到抠图模型,例如标题“抠图模型”、取值 动漫风格 anime-seg。
  • 原因:isEditorUserVisibleGenerationInputField 的实现是 field.title.trim() !== '处理模型',只按这一个标题字符串精确排除,既不识别语义也不探测取值。历史数据里存在标题不同但语义相同的字段,直接穿过过滤器;服务端 User/Public mapper 只清理 generationInputs 顶层的 screenColorHex / mattingProvider / mattingModel,不遍历 fields 数组,所以两侧都不会拦。
  • 处理:本条目前不修——历史素材不迁移、不回溯清理是明确的产品决策,不得据此判定为缺陷或提交「修复」。约束只对新写入生效:新产生的 generationInputs.fields 不得再写入任何内部处理模型字段,无论标题叫什么。若将来要扩大过滤范围,先对生产 generation_inputs_json 做一次标题去重查询枚举真实存在的历史标题,不要仅凭测试夹具推断清单。
  • 验证:ImageCanvasMetadataModalView 与 ImageCanvasExportModel 共用同一过滤口径,改动其一必须同时覆盖另一侧;新增过滤标题时需同时确认图片信息弹窗与画布 ZIP 两条路径。
  • 关联:src/components/image-editor/ImageCanvasGenerationModel.ts(isEditorUserVisibleGenerationInputField)、src/components/image-editor/ImageCanvasExportModel.ts、src/components/image-editor/ImageCanvasMetadataModalView.tsx、src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts、docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md。

下游 manifest 回调测试不能冒充实时数据源(2026-08-05)

  • 现象与辨识:回调重投影单测绿色,但后台改 manifest 后打开中的工作台仍旧,先查真实失效事件是否驱动 manifest 重读;测试直接调用 onManifestChange 只证明下游桥。
  • 处理:生产验证必须贯通真实事件→按项目 single-flight 权威重读→工作台资源/任务/版本投影。GUI 内事件与外进程转发是不同边界,不能因 Runner 没有 GUI AppHandle 就只补普通 Tauri 事件并宣称接通。
  • 隔离:挂载、项目路径和 scope version 同时校验,迟到读取不得写新项目;集成夹具等待真实 listener 注册后发事件,不用直接回调或轮询伪造实时数据源。

manifest 与 revision 必须作为同一一致快照发布

  • 现象:素材提交或上传后出现“资源清单更新被拒收”,真正的新资源快照被当成同 revision 分叉。旧 manifest 的 effect 迟到读取新 revision,会拼出“旧内容 + 新版本”;上传前读 revision、上传后再读 manifest,则拼出“旧版本 + 新内容”。上传本身推进 revision,后一种错误必然发生。
  • 原因:把不同时刻独立读取的内容与版本当成权威快照;callback 到达顺序和 eventId 去重都不能修复配对身份。
  • 处理:普通投影通过 rereadAuthoritativeProjectManifestSnapshot 执行“revision 前读 → manifest → revision 后读”,两次 revision 一致才发布,漂移时由调用方有界重试。素材 command/event 使用事务返回的完整 manifest 及对应 revision;上传与上传后的配对读统一由 uploadProjectAssetFilesAndReadSnapshot 持有,调用方不得传入写盘前 revision。父级核对 projectPath + projectId,单调接受更高 revision,拒绝低 revision;同 revision 的冲突只比较受保护的 assets + versions,完整重复与非 CAS 簿记变更分别处理,见“同一版本号上的「簿记写入」被判成 CAS 冲突”。
  • 恢复文案:unresolved 也可能表示重读成功但拿到旧快照或撕裂配对,应写“未能按磁盘清单重新对齐”,不能一律写“重新读取磁盘清单失败”。
  • 验证:覆盖 command/event 两种先后、提交后旧读取迟到、同 revision 受保护内容分叉;上传假后端必须真实推进 revision,直接调用生产函数并断言 merge 为 accepted 且拒收决策为空,另用旧配对方式断言 revision-conflict。projectResourceLiveUpdateModel.test.ts 是生产函数级证据;资源面板上传到拒收提示条尚无端到端 UI 用例,不把函数级结果表述为端到端验收。
  • 关联:apps/ai-game-creator-shell/src/view/project-development/projectResourceLiveUpdateModel.ts、index.tsx 的 uploadResourcePanelFiles、src/features/app-shell/WorkspaceLauncher.tsx、src/App.tsx、apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts。

prepared journal 之前同样存在正式事务崩溃窗口(2026-08-05)

  • 现象:事务依次安装 before/after 快照后才写 journal;若进程在首个快照、全部快照或 journal 已写但 ledger 未写时退出,重启扫描看到 transaction 目录却无法进入原先只覆盖 prepared 之后的恢复状态机,可能留下孤儿目录或阻塞项目后续提交。
  • 原因:把 prepared 当成事务的第一个可观察持久阶段,忽略了构造 prepared 证据本身也由多次原子文件安装组成。
  • 处理:把首个快照、全部快照和 journal 后/ledger 前加入故障矩阵。无 ledger 时只允许清理受控快照与本模块临时文件;若 journal 已存在,还必须证明正式目标不存在、manifest 和 project revision 精确等于 before。未知文件、正式文件存在或权威状态漂移全部失败关闭,不能递归猜测清理。清理后同步 transaction 父目录,并允许同 commit/idempotency 身份安全重放。
  • 验证:故障矩阵逐阶段恢复;额外用同一幂等身份在快照残留清理后提交两次,必须得到一次 committed、一次 already-committed,manifest 仍只有一个 canvas asset。

Tauri 生成与资源编辑恢复不能依赖 UI 快照、旧 Key 指纹或队列首项(2026-08-11)

  • 现象与原因:重启后丢任务/版本/refine,换 Key 误报配置变化,成功 Provider 被重复调用,或老失败任务挡住恢复,通常是把 UI 快照、凭据指纹、队首项当 durable 事实。当前页面资源 ID 不等于远端稳定 objectKey。
  • 请求身份:新账本冻结源快照、请求字节与稳定对象引用;旧账本只从权威 manifest/完成任务/版本有界补证。refine 按项目、意图、源素材和 active sidecar 唯一发现,多候选失败关闭。恢复复用原 operation,提交前复验源摘要。
  • 付费窗口:调用前持久化 request-issued,Provider 成功正文先写 durable handoff 再解析/staging;issued 无 handoff 只能对账,不能重新付费执行。所有扫描目录项均消耗预算,不能只数识别出的 JSON。
  • Key 与服务:服务指纹哈希规范化的 External base URL,保留 URL 路径;平台账号模式还绑定会话服务地址与用户身份,不能把同 origin 的不同服务视为同一身份。Key 轮换不改变已受理 operation;旧 Key 绑定指纹只能经快照挑战显式迁移,确认前零网络,受理后 401/403 换 Key 也仅 GET 原 operation。确认 UI 只显示无路径/凭据的 origin。
  • 恢复队列:展示全部后端权威 operation,读取失败不是空队列。remote-failed 不重放,只能显式 archived 并保留账本;reconciliation-required 不可归档。
  • 事务:asset journal 按 prepared→media-installed→manifest-written→revision-written→committed,只对可证明状态前向恢复。未证明目标写入时严核 before/after;已证明目标 asset/media 与 target revision 后,允许后续合法提交推进 manifest/revision 并补同一 ledger。
  • 清理:durable committed 后,仅 staging 与正式媒体摘要一致、manifest 按 ID 或路径唯一匹配 journal 资产时尽力删除。删除 I/O 失败仍为 committed;身份/媒体漂移保留 staging 并对账,不能误报提交失败或猜测清理。
  • version:journal 冻结完整 project revision before/after;manifest 已有派生子版本却缺 journal,或旧 journal 不能证明 revision 推进时失败关闭,不凭当前状态猜已提交。
  • 关联:AGC 资源、恢复与账号绑定合同。

2026-08-21 已有资源 ID 不等于可用于快速编辑的 canonical 来源

  • canonical 来源:manifest source.resourceId 可能是历史 Game Agent 私有 kind;仅验证 ID 前缀不能避免 External 入队前 unsupported-source-kind。本地 kind 映射只在重新登记来源时生效。
  • 导入事务:隐藏 file input、逐张 draft-media、前端补层再 autosave 不是批量事务。原生多选由后端批量更新草稿;安装首个媒体前先完成所有可失败的 ID、路径、层序和 revision 计算。
  • 结果未知:原子草稿写入报错且回读失败,不能推断未提交后删除可能已被引用的媒体;保留现场报对账,回滚删除失败同样显式上报。
  • 失败任务:React filter 不删除 durable ledger/draft 投影,重启会复活。只通过后端归档明确失败任务,未知结果不能删除。
  • 关联:AGC 资源、恢复与账号绑定合同。

运行中 generation 与 refine 来源身份不能按 create 链路处理(2026-08-21)

  • 草稿 hydrate 后同步等待远端 generation recovery,会让生命周期长期停在 recovering,连平移和选图也被 inert。关键事务恢复与任务恢复必须拆开:前者先完成,后者后台推进且失败只进入任务/notice。
  • refine 提交若无条件校验 source.resourceId == local-asset:<assetId>,会拒绝本来合法的 editor-resource-* 已登记来源,并可能把事务卡在 revision-installed。创建与未登记本地资产才补 local identity;已登记 refine 必须保留并按解析后的来源身份回读。
  • 画布内部 absolute overlay 只能覆盖宿主网格,嵌在左右分栏时不会遮住整个窗口。阻断性失败必须 portal 到 document.body,并用 fixed inset 覆盖整个 WebView;测试应验证 portal 的直接宿主和 fullscreen modifier。

素材画布旧提交不能只按当前状态猜测恢复(2026-08-22)

  • 现象:同一 refine 资源的旧事务停在 reconciliation-required,后续另一笔事务已成功并更新当前 manifest/revision。恢复器只对比旧事务自己的 before/after 快照时,会把“已被后续提交取代”永久误报为需要人工对账。
  • 处理:只有后继事务 committed、项目/草稿/资源身份一致、旧 after 与后继 before 精确衔接、后继 after 与当前 manifest/revision 精确一致、两份候选文件与 manifest 唯一引用都验证通过时,才把旧事务标为 superseded;候选文件和审计记录都保留。任何证据不完整继续失败关闭。
  • 门禁:同一资源存在 prepared 或 reconciliation 事务时,新的“设为正式图”提交必须先被拒绝并引导安全恢复,不能继续制造另一笔可能覆盖旧结果的提交。committed/rolled-back/superseded 是可继续后续提交的终态。
  • 关联坑:正式文件路径会追加 commitId,重新打开 refine 时不能把整个文件 stem 当作下一次素材名,否则多次精修后超过 80 字符并阻塞提交。路径只推导显示名,且必须循环剥离历史 --<uuid> 后缀;确定性名称/用途校验要发生在读取候选与 staging 前,并按输入错误处理而不是进入事务恢复。

素材画布候选确认不能复用普通草稿保存(2026-08-23)

  • 现象:候选挂载后用 fire-and-forget updateDraft 确认时,候选本身不增加前端 document version;用户立即设为最终图、导入、生成、归档或放弃草稿会携带旧 revision 与隐藏确认写入竞态。重新打开含 candidate-ready 的草稿还会重复保存同一画布、无意义推进 revision,并制造多窗口冲突。
  • 处理:候选确认使用独立幂等宿主操作,在现有草稿锁内只更新匹配当前项目、草稿、generation、仍存在权威图层且尚未确认的私有 ledger,记录当前草稿 revision,但不改写草稿或推进 revision。普通草稿 update 只保护未确认候选,不再顺带确认。
  • 前端门禁:确认任务进入 autosave FIFO,连续候选 ID 合并并串行处理;所有消费 draft revision 的提交、导入、生成、归档和 discard 必须先等待确认屏障。确认失败不得继续 revision-sensitive 操作,重复 hydrate 可以重发但后端必须零写入。
  • 验证:前端覆盖确认 pending 时立即设为最终图和删除,断言提交 / update 在确认完成前均未发生;重复打开不调用 updateDraft 且 revision 不变。Rust 覆盖首次确认、重复确认、未知或非候选 ID、普通 update 不确认,以及确认后显式删除。

同一本地项目切换账号后不能继续信任 manifest 远端 ID(2026-08-23)

  • 现象与根因:同一本地项目从 A 切 B 后派生/恢复报无权限,先查 manifest 远端 ID 是否被当成当前账号可编辑引用;这些字段仅是历史 provenance。
  • 当前 binding:所有远端编辑/派生先解析当前 principal 的 sidecar 绑定,缺失才按本地 asset ID、SHA-256、媒体类型和 canonical kind 重登记。禁止按标题、manifest 远端 ID 或另一账号 committed 账本采纳对象;A 在途 operation 仍属 A。
  • 创建与升级:首次 binding 须本地互斥加远端稳定 Idempotency-Key,覆盖远端成功但 sidecar 未落盘的崩溃窗口。canonical ID 升级只能按本地 asset/路径/摘要白名单恢复旧账本;新增必填 binding 指纹必须升 schema、用独立严格 legacy wire 验证后迁移,不 default 字段或先回写。
  • 在途账号:获得 operationId 先落盘;每次 poll、download 和 commit 复验原 session,切号后既不能继续用 A Token 发网络,也不能把 A 结果装给 B。校验到安装必须由冻结 session 租约线性化;手工入口同样要 durable 幂等身份,不能只检查首个 POST。
  • 双阶段会话:auth generation 与 native 单调 generation 分开;native mutation 串行,入队冻结 token/origin/user。Rust 确认前不得替换 committed Token,迟到 install/clear 按 desired account/null 对账;对账失败清空 committed 会话,不恢复未确认候选。
  • origin 与 refresh:登录/hydrate/refresh 首请求前冻结 origin,HTTP 与 native commit 共用快照;refresh single-flight 按 origin 隔离。stale early return 先恢复当前 committed/desired Token;旧 owner 迟到失败只返回 stale,不 clear 或全局发布 failed 误登出 B。
  • 账本与列表:owner 绑定、服务指纹/挑战与确认写入均持同一 session 租约并校验 ledger owner,公开 request/confirm 入口自身有门禁。恢复扫描按 userId+API origin 过滤,未绑或不完整远端账本隐藏、纯本地编辑保留;auth 变化清空相关 UI 并使迟到 read epoch 失效。
  • 追加审计:asset.register 返回错误不能证明 append 未持久。file/manifest 已落盘且审计成功或未知时,保留 file+manifest+audit 并标对账;不能删文件/回滚 manifest 制造第二种不一致或复制审计/重扣费,后续 binding 失败同义处理。
  • Runner 跨 GUI:GUI 重启后低 generation 不能用旧 Runner 高 generation 直接 CAS。owner 锁生成随机 epoch,每次会话推进 durable revision claim,只允许完全匹配 claim 的 attach 安装会话;Runner 持续比对 claim,失配立即清会话并拒绝请求。GUI 同步失败隔离/停止旧 Runner,OS owner 锁不单独等于授权。
  • 核验:A→B→重启→A 时 B 的 URL、请求体和引用不含 A 身份;Developer Key fixture 不能代替平台账号隔离证据。
  • 关联:AGC 资源、恢复与账号绑定合同。

资源分类须区分 kind 变更、落盘值与显示派生

  • 现象与原因:重登记出现新 kind+旧分类,或只改标签静默覆盖手动分类,先分清 kind 写入、persisted category 与 effective 显示值;显示自愈不具有写回权。
  • 写入:canonical kind 以 Rust 共享声明及生成 TS union 为唯一词汇,严格等值,不做别名猜测;未知原值收为 unknown 并记上下文。只有 kind 真正变化才重派生 category,同 kind 保留手工落盘分类;回写用 persisted category,落盘未知分类失败关闭。
  • 读取:仅 persisted=unclassified 且 kind 可明确派生时采用 effective 值,其余信任落盘;UI 与 Direct 工具投影同口径。
  • 盲区:当前显示自愈也覆盖“显式设为待归类”的意图。若要保留该意图,先在合同区分未设置与显式 unclassified,不直接取消自愈或把显示值回写。
  • 关联:AGC 资源、恢复与账号绑定合同。

2026-09-12 同一版本号上的「簿记写入」被判成 CAS 冲突:判据面比契约宽,每次预览起停都弹「资源清单更新被拒收」

  • 原因:revision 保护资源与版本,预览起停、项目名等簿记写入可以不推 revision。前端用整份 manifest 的 JSON 指纹判断 CAS,会把有效簿记更新误判为冲突并丢弃快照。
  • 处理:CAS 指纹只覆盖 { assets, versions }。同 revision 下,受保护内容变化仍报 revision-conflict;整份清单相同视为 duplicate;仅非 CAS 簿记不同则 accepted,revision 不推也不退。
  • 验证:同版本预览状态变化应被接纳;同版本资源变化仍须拒收。另保留快照与 revision 配对读取,不能靠收窄 CAS 判据掩盖错误配对。
  • 关联:apps/ai-game-creator-shell/src/view/project-development/projectResourceLiveUpdateModel.ts、apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts。

AGC 图集数量与返回次序不表达用途(2026-10-05)

  • 现象与机制:sliceCount 只约束服务端后处理,不进入模型提示;四类需求不等于四个连通域,返回次序/历史文件名不表达用途。按源摘要隔离中性切片,交付实际集合及路径标注预览,由 Agent 看图识别;零片仍可交付有效总图与告警。
  • durable 边界:动态集合进入 journal、重生成快照、完整性检查和工具投影;旧数量请求必须定位唯一原动作槽,复用原请求字节、幂等键和 operation,不删指纹字段后重发付费请求。保留 sliceMode 回显以便重放;图集仅支持 connected-components,不再保存网格维度。
  • 补齐:两图美术包不是 completed。先恢复现有阶段请求,无请求才只读查找并按需生成;查询失败、身份冲突或损坏账本不能当资源不存在。失败保留有效基础图,三张主图齐全且可验证才交付。
  • 身份:规范图原始 ID 与当前账号重登记参考 ID 可不同;完成/恢复按阶段结果核对图集资源、对象、任务,不额外要求两种参考 ID 相等,来源仍由现有绑定校验。

画布与编辑器

2026-10-05 运行画面点选的高亮框被尺寸上报当成页面内容:点选后预览自己缩放

  • 现象:three 项目里点选 3D 对象后预览画面自己缩放、视角异常(相机 aspect 与重新适配的距离都变了),点选结束后视口仍停在放大后的尺寸。实测夹具里是自激振荡:10 秒内 1459 条尺寸消息、1457 次 iframe 尺寸/缩放变更、1454 次 resize,相机 aspect 在 1.607143 ↔ 1.421112 之间来回变。
  • 原因:高亮框是 document.body 下的 div[data-genarrative-preview-inspect](position: fixed、尺寸随命中对象),而尺寸上报的 measureContentBounds 用 createTreeWalker(body, SHOW_ELEMENT) 遍历所有元素并把 rect.right/bottom 计入内容尺寸。three 档的高亮矩形来自 Box3 八角投影,物体贴近相机时投影盒远超画布(实测 6607×4721、left/top 为负)→ 上报内容被撑到 3756×2643(视口只有 900×560)→ 宿主 resolveLocalGamePreviewFitLayout 把 iframe 改成 3756×2643 + scale 0.2119 → 游戏 resize 重算相机 → 视口变大又让投影盒更大,如此循环。
  • 结论(现行口径):① 桥自己的节点一律不进内容尺寸测量——measureContentBounds 的 TreeWalker 用 acceptNode 对 data-genarrative-preview-fit / data-genarrative-preview-inspect 返回 FILTER_REJECT;以后新增任何桥注入的 DOM 节点都要带上这两个标记之一,否则会重新引入这条反馈。② 引擎档命中矩形统一裁剪到画布可见范围(clipInspectRect;与画布无交集时退化为指针点矩形),不再出现比画面还大的高亮框。
  • 回归:apps/ai-game-creator-shell/tests/localPreviewInspectSizeStability.test.ts(5 例:桥节点不参与测量、同尺寸普通节点仍计入的对照组、进入检查模式与 hover 后尺寸与宿主适配布局不变、投影盒超出画布时载荷被裁剪、与画布无交集时退化为 1×1)。
  • 关联:resources/preview/local-preview-fit.js(measureContentBounds / clipInspectRect / threeInspectTarget / phaserSelection)、features/project-workspace/LocalGamePreviewFrame.tsx(resolveLocalGamePreviewFitLayout)。

2026-09-21 画布卡片「拖一下就触发点击」:阈值与点击抑制必须是卡类手势的一份判据

  • 现象:拖动未生成的资源占位卡(背景音乐 / 音效等所有类型)松手后,卡片自己的点击语义被多执行一次——生成浮层被顺手弹开或收起。
  • 成因:浏览器在 pointerdown 与 pointerup 落在同一节点上时一定会补一次 click,与中间移动了多少无关。生成占位卡那条手势把「收到过 pointermove」当成拖动信号(按下时浏览器就可能补一次零位移的 move),而且没有任何点击抑制;资源卡那条链路早就用 RESOURCE_CANVAS_DRAG_THRESHOLD(5px)+ skipNextResourceCardClickRef 处理过这件事,两条链路各写一套,于是只有占位卡漏。
  • 处理(现行口径):卡类手势只有一份判据 resourceCanvasGestureExceededDragThreshold(features/resource-canvas/resourceCanvasCardGestureModel.ts,阈值仍取 RESOURCE_CANVAS_DRAG_THRESHOLD);拖动收尾那次 click 由手势层登记一次性抑制、宿主在「点卡片」的入口消费(占位卡是 consumeDragClick,资源卡是 skipNextResourceCardClickRef),抑制活过一个宏任务就清干净。「拖动中」样式也从越过阈值那一刻起才亮。
  • 相邻一档:从删除按钮起手、松手落回卡片的手势,click 会被派给共同祖先(卡片)——判据要看起手点(ResourceCanvasGenerationPlaceholderCardView 的 gestureOriginRef),不能只看移动距离。
  • 易错点:把「拖动样式」放在 pointerdown 上置位,等于承认「按下即拖动」,紧接着的点击又会被自己的抑制吃掉;阈值与抑制必须同时按同一判据走。

2026-09-21 画布浮层几何只能按卡片的真实屏幕位置算,分页锚点必须让开钉死的标题栏

  • 现象与原因:生成浮层在下半屏被裁或靠边越界,先查是否拿“安全带高−卡高”代替真实卡片屏幕顶边;追加卡不保证在安全带顶端。
  • 处理:按真实屏幕矩形计算卡下可用空间,不足则覆盖占位并收进安全带;水平锚点按浮层宽度夹取。CSS 宽度与几何模型保持一致。
  • 页切换:工具条换行或不同页面标题栏高度改变后,锚点须按当前页 chrome 重新避让;不能写死 top 或只用总览页坐标。

JSON 卡片显示与 UI 编辑能力必须同源

JSON 的文本读取分支不等于卡面应该展示原始 State 摘要。卡片、缩略图及编辑器入口共同消费受控文本预览的 uiDesignAssetId;只有原生复用 UI 持久化合同校验 schema、完整 State 和项目/资产身份后才设置它。普通 JSON 保留 JSON 代码预览,不按 kind: UI/ui 或 schema 字符串片段猜测编辑能力。已有合法 UI State 的加载/保存不依赖 kind 精确大小写,但新建初始化仍保留正式 UI 资产门禁;缓存与项目切换须保留现有身份隔离。

2026-09-14 UI 编辑器返回后资源画布滚轮平移失效

  • 现象:资源管理打开 UI 编辑器再返回后,资源画布滚轮平移/缩放不再响应;返回前同一手势正常。
  • 原因:资源画布的非 passive wheel 监听绑定在 resourceBookManagerRef 当前 DOM 上,但 effect 只依赖 handleResourceBookWheel 与 mode。UI 编辑器切换会卸载旧 manager 并挂载新 manager,依赖不变导致新节点没有重新绑定监听。
  • 处理:将 uiEditorRoute 纳入 wheel effect 依赖,使进入/退出 UI 编辑器时先清理旧节点监听,再给返回后的新 manager 绑定同一处理器。
  • 验证:npx vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts -t "restores resource canvas panning";回归用例覆盖打开栏目、wheel 平移、进入 UI 编辑器、返回并再次 wheel 平移。
  • 关联:apps/ai-game-creator-shell/src/view/project-development/index.tsx、apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts。

2026-09-14 AGC 资源画布不要恢复「无条件重派生」,否则新增一张素材就整张重排

  • 现象:用户生成一张新素材后,画布上既有卡片全部移位,刚摆好的位置失效。
  • 原因:useProjectResourceCanvasLayout 的 rederiveAutomaticPositions=true 会让每一次资源协调签名变化(新增 / 删除素材、改标签、改分类、拓扑签名变化)都丢掉全部 manuallyPlaced=false 坐标整体重算。签名里必然包含新素材,所以「新增一张素材」就等于「整张画布重排」;两个 hook 当时都开着它(type 侧无条件 true,dependency 侧长期等于 resourceGraphReady)。
  • 处理(现行口径):默认一律 preserve(只补新卡)。整张重排只由「整理画布」按钮调用 hook 的 rederiveNow() 发起,或由「关系图首次就绪」那一次按项目作用域的一次性 flag 发起。不要把 rederiveAutomaticPositions 改回长期 true / resourceGraphReady,也不要为「拓扑变了要立刻重排」再加自动触发点——那正是本条要修掉的行为。
  • 易错点:① 一致性判据是 reconcileResourceCanvasLayout 输出里每个分区按 (y, x, resourceId) 排序后的数组与来源逐项比较,所以手工构造 sidecar 夹具时要按同序写,否则会被判成「变了」而多写一次,用例里会看到意料之外的写回;② 关系图首次就绪那一次重算要等该侧 sidecar ready 之后再发(关系图可能先就绪),否则这一次会被吃掉;③ 依赖侧的一次性 flag 按项目作用域记账,不要挂到 resourceGraphReady 这类会随排序 tab 反复翻转的值上,否则每次切回依赖视图都会重排一次;④ 新素材聚焦按 manifest.assets 的新增 id 判定,首次打开 / 切项目必须先登记基线,否则一进工作台就跳到最后的卡上;⑤ 不要为了让「整理画布」按钮"一定有反馈"而把 changed 门拿掉——重算结果与当前坐标一致时不写盘是既有合同,关系图 producerMappingTruncated 时强行写回等于把一份来自不完整关系图的自动布局持久化(appSurface.test.ts 的 keeps trusted truncated-graph depths through the workbench without persisting a flat automatic layout 会红)。
  • 验证:npx vitest run apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx(8 条);把两个 hook 配置改回旧口径会红 3 条。
  • 关联:apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts、apps/ai-game-creator-shell/src/view/project-development/index.tsx。

2026-09-12 画布滚轮要按 DOM 归属判定,portal 出去的浮层不能把滚轮让给画布

  • 现象:资源卡「快速编辑」里用 @ 开出「选择素材」浮层后,在选择器列表上滚鼠标滚轮,列表自己在滚,背后的资源画布也一起平移 / 缩放(用户口语:「滚轮还是回滚到画布上」)。
  • 原因:选择器与输入区候选菜单 createPortal(..., document.body),DOM 上不在画布管理区里;但 React 的 portal 事件沿 React 树 冒泡(React 把委托监听挂在 portal 容器上),所以它们的 wheel 照样走到画布场景根的 onWheel,被当成画布手势消费。React 的 wheel 委托监听是 passive 的:preventDefault() 是空操作(只报 warning),真正出问题的是视口状态被改写——所以「事件没被 preventDefault」不能作为「画布没吃这一下」的判据。
  • 处理:滚轮归属与「点外部关闭」共用同一份浮层口径(src/components/image-editor/useImageCanvasFloatingOptionDismiss.ts:isEventInsideFloatingOverlay + isFloatingOverlayWheelEvent)——DOM 不在宿主边界里的(portal 出去的一律算浮层)与已登记为浮层内部的都归浮层。资源画布只保留 handleResourceBookWheel 一处守卫,留在画布 DOM 里自带滚动区的浮层(快速编辑 / 信息 / 筛选,见 RESOURCE_CANVAS_WHEEL_OVERLAY_SELECTOR)按同一入口登记,不要再逐浮层加 stopPropagation。判据判不出归属时(没有元素目标)不抢滚轮。
  • 排查顺序:先确认浮层是不是 portal 出去的;是的话不要先怀疑 CSS overflow、overscroll-behavior 或事件被 preventDefault,直接查画布宿主上的 onWheel / onPointerDown 有没有做 DOM 归属判断。
  • 验证:npm run test -- apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx(判据单测,含 portal / 共享弹出层 / 已登记浮层三种来源与「判不出归属不抢」)与 npm run test -- apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx(真实事件序列:选择器列表里派发 wheel → 画布 data-resource-viewport 不变、浮层自己收到该事件;对照组:场景根上派发 wheel → 视口照旧变化)。
  • 关联:src/components/image-editor/useImageCanvasFloatingOptionDismiss.ts、apps/ai-game-creator-shell/src/view/project-development/index.tsx(handleResourceBookWheel)、apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts

资源画本转场必须区分视口、world 与真实 First 来源

  • 坐标陷阱:指针入口绑定未变换的 viewport,平移/缩放只作用 world,否则移走内容后空白区不能再拖。DOMRect 是屏幕坐标,缩略入口先逆 viewport,world 内 FLIP 位移须除 world scale 并换算原点。
  • 标题栏:钉住层放场景根,与 world 同级。反向 scale 只能修几何、不能保证文字光栅清晰;按 key 保存旧 First,在新宿主继续真实节点转场,不用克隆交接。
  • 生命周期:提交前记录可见 First、提交后测 Last;异步尺寸变化也从当前像素重基,旧完成回调由 token 失效,不用定时器猜业务提交。
  • 缺 First:总览未渲染的卡只能用同栏目/摞的最深可见卡作本次合成 First;保留真实摞列号、锚点收尾清理,运行中重基不认领旧锚点。其它无有效 First 仅淡入,不取不可见旧节点的屏幕外坐标。
  • 验证:jsdom 不执行真实布局/动画,连续中断、位移、比例和文字清晰度要在浏览器看;reduced-motion 不播放。

UI 设计 State 的 strict JSON round-trip 不能混用两种浮点序列化表示(2026-08-19)

  • 现象:为节点拖拽/缩放生成非整数 Transform 后,保存报“UI 设计 State 安装后回读与待写内容不一致”;由于读取主文件失败关闭后恢复 .previous,后续回读表现为刚导入的 spirit/sprite 资产丢失。
  • 原因:写入使用 serde_json::to_vec_pretty,它对 f32 输出短十进制(如 348.5318);读取端却以 serde_json::to_value 重建 canonical JSON,重新扩展为精确二进制值(如 348.53179931640625)。两者作为 serde_json::Value 不相等,合法的新主文件被误判为不受支持,然后错误回退到旧恢复副本。
  • 处理:严格 envelope 检查必须使用与磁盘写入相同的 serialize_ui_design_document 再解析为 Value,仍拒绝未知字段/值,却允许合法 f32 的稳定文件表示;不可再把 to_vec_pretty 与 to_value 的数字文本直接比较。
  • 验证:持久化回归使用带 spirit sprite、Image.target_graphic 引用及拖拽式非整数 Transform 的完整 State,断言保存返回和后续 load 均完整相等;同时保留旧空对象/未知 envelope 字段拒绝、CAS 和 .previous 恢复测试。
  • 关联:apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs。

分区依赖线必须与资源卡共享逻辑坐标、缩放和滚动

  • 现象与根因:滚动或缩放时线段追赶、漂移、穿过标题栏或串到其它分区,源于全局 SVG 与 section plane 不共享 transform/scroll;getBoundingClientRect + RAF + state 重建屏幕端点只能异步追赶合成层。只重测分区原点不能修复,多个 viewport 的 clipPath 并集也不能表达边的分区归属。
  • 坐标与归属:每个固定分区在自己的 .game-resource-plane 内持有独立 SVG,卡片与路径直接使用同一布局逻辑坐标、父级 scale 和原生 scroll,viewport 的 overflow 负责本区裁剪。每区至多一个 observer 和 RAF,同帧 scroll 合并测量,卸载或 mode/项目切换清理;DOM 测量仅换算本区逻辑 viewport,不决定端点身份或主路径坐标。
  • 离屏与箭头:不能以卡片完整落在 viewport 内作为关系挂载条件。精确引用的源端或目标端单独离屏时分别绘制 outgoing/incoming 边界继续线,两端离屏才隐藏;目标锚点预留箭头间隙,marker 使用 userSpaceOnUse 并允许 overflow,自环整体外移避免箭头压卡。搜索隐藏端点属于业务可见性过滤:任一精确端点被搜索隐藏时整条橙线隐藏,与 viewport 裁剪分开;task-flow 始终不渲染。
  • 验证:apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts 与 appSurface/project-development.suite.ts 覆盖两个分区独立挂边、只滚动一分区时另一分区 path/viewport 不变、同帧多次 scroll 仅一个 RAF、缩放后卡片与 SVG 同 plane、双向继续线、两端离屏隐藏、箭头/自环、搜索过滤及 observer 清理。
  • 关联:apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx、docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md。

共享画布必须显式提供实例作用域、根类与宿主样式

  • 现象与辨识:第二张画布小地图移动第一张、重复挂载后 wheel 多次触发,查全局 DOM 查询与 cleanup;工具条落入文档流或框选透明,查宿主 chrome 样式和主题根。
  • 实例:小地图优先当前 viewport 查询,只有 document 唯一候选才兼容旧单实例;portal 支持实例 root。wheel、observer、RAF 卸载逐项释放,两宿主共享同一 packages 源码并 dedupe React/ReactDOM,避免重复 React 的 Hook 错误。
  • 样式:只复用子组件的宿主也必须挂.genarrative-image-canvas 根类;无 fallback 的 token 缺失会使整条声明无效。共享 styles 提供基础画布,不等于网页全部 chrome;宿主只补实际使用规则,不导入整站 index.css 或复制整套样式。

依赖聚类不能把聚合 task-flow 展开为资源两两边

  • 现象:为了让 task-flow 的两端资源靠近,若对每个 source × target 构造边,资源多的任务流会迅速放大内存、排序工作和虚假关系;同一图输入还可能随着成员枚举顺序出现不稳定排列。
  • 原因:task-flow 的业务语义是任务对的聚合流,不是资源间的完整笛卡尔依赖;dependency depth 也已经由 Rust SCC read model 权威计算,前端不能用布局边重建业务方向。
  • 处理:布局分组把每条 flow 作为一个临时流节点,仅与其 source / target 成员相连;中位数扫描读取流另一端成员现有 rank 的中位值。遍历保持迭代式,扫描轮数固定,所有初始序和最终平局都以稳定资源 ID 收口。用于自动重派生的拓扑签名先按固定分类过滤并规范化稳定 ID,再生成固定大小摘要,不能把显示名、卡片大小或浏览器几何加入签名。跨分类 reference 与跨分类-only task-flow 不进入前端布局;所有 task-flow 都不进入 SVG 或画布关系说明。producerMappingTruncated 时继续只消费现有同类型精确引用,不重建 task-flow。
  • 验证:纯模型覆盖多入、多出、聚合 task-flow、环、4096 链和重复输入坐标一致;Hook 覆盖仅改邻接、深度不变仍重派生自动坐标。不得把搜索后的可见集传入聚类。
  • 关联:apps/ai-game-creator-shell/src/view/project-development/resourceCanvasLayoutModel.ts、useProjectResourceCanvasLayout.ts、resourceDependencyGraphModel.ts。

四分区 SVG 不能把跨类型业务关系当成可绘制几何

  • 现象:跨分类资源位于彼此独立滚动和裁剪的 viewport;若仍绘制一条全局 SVG 路径,只会在两个分区中留下没有完整上下文的断线,滚动时还会看似随机出现或消失。同一卡片多边若都锚在中心点,也会让合法的同类型线叠成一束。
  • 原因:Rust read model 的业务关系范围大于资源管理画布的展示合同;四分区视图没有跨标题栏的合法连线走廊。几何层直接遍历全部 reference edge 等于把业务真相误当成全部可视关系;单中心端口又忽略了边的稳定身份与对端顺序。
  • 处理:保留 Rust 图与权威深度;布局拓扑按资源分类过滤 reference 和 task-flow 超边切片,关系说明与 SVG 则只消费同类型精确引用。相同分区内按对端坐标、稳定边 ID 为同侧精确边分配有界端口。不要通过改变端点、隐藏同类型合法精确边或生成资源笛卡尔积来换取整洁。
  • 验证:同时覆盖跨分类精确引用与跨分类-only flow 不聚类 / 不绘制、全部 task-flow 零 SVG / 零画布关系说明、同侧多边端口不重合且重复输入路径一致、同类环 / 自环和 4096 项回归。
  • 关联:apps/ai-game-creator-shell/src/view/project-development/index.tsx、ResourceDependencyOverlay.tsx、resourceCanvasLayoutModel.ts。

依赖图未就绪时不能先初始化资源布局

  • 现象:首次打开 dependency 画布时所有资源短暂按深度 0 排列;Rust 图返回后连线正确,但卡片仍停留在同一列,错误自动坐标还可能已经写入 sidecar。
  • 原因:资源图和布局读取独立异步启动,布局 Hook 在图未返回时使用空图资源创建 fallback;后续 reconcile 按旧合同保留全部已有坐标,真实 producer 与 dependency depth 无法纠正首次自动位置。
  • 处理:dependency 模式增加按项目与资源输入隔离的图加载屏障,ready / failed 前不启动布局 Hook 的 fallback、读取、协调或保存。Rust read model 返回确定性依赖深度;已有布局只永久保留手动位置,自动位置按最终图重新派生。type 模式不受图加载影响。
  • 验证:用 deferred graph Promise 断言终态前 Tauri layout read/update 调用均为 0;图就绪后首次坐标直接按最终深度生成,旧 scope 迟到结果无效,手动坐标不变且相同自动布局不增加 revision。
  • 关联:apps/ai-game-creator-shell/src/view/project-development/index.tsx、apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts、apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs。

等价 Runtime 投影刷新不能清空资源依赖图(2026-08-10)

  • 现象:专业 Agent 运行期间,资源画布中的卡片按轮询节奏整批消失并立即恢复;停止产生新的 Runtime 时间戳后闪烁减弱或消失。
  • 原因:专业 Agent 轮询会重建结果数组;资源内容虽然相同,前端图读取 effect 仍因数组引用变化重新执行,并先把图和布局置空。布局位置暂时缺失时,所有资源卡都会返回 null。
  • 处理:用包含项目与资源输入的语义 scope key 稳定图请求参数,等价输入不重复读取;同一项目的资源集合确实变化时,异步刷新期间保留上一个已解析图和布局,只有首次加载或切换项目才启用空图屏障。刷新失败继续展示旧快照,不能用瞬态失败清空画布。
  • 验证:AppSurface 先用全新但内容相同的 Agent 结果数组 rerender,断言图读取仍只有一次且原卡片 DOM 保持连接;再增加真实资源并延迟第二次图响应,断言旧卡片在刷新窗口持续挂载,新图返回后新增卡片正常出现。
  • 关联:apps/ai-game-creator-shell/src/view/project-development/index.tsx、apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts。

场景队列终态不保证首次项目快照已经收口生成占位

  • 现象:游戏场景任务已经显示完成,但画布仍保留 generating 占位;场景链路又禁止用本地结果补层,因此当前会话可能一直停在生成中。
  • 原因:外部生成任务终态与项目画布投影不是同一个原子观测点。队列轮询先看到 completed 后,紧接着的首次项目 GET 仍可能读到同一 dialogId 的未收口占位;若调用统一回读函数时没有传 completion dialog ID,函数无法识别该快照仍未完成,也不会执行已有的有界延迟重读。
  • 处理:游戏场景队列调用要把本次占位 dialogId 传给 applyQueuedEditorGenerationProject。首个快照中该 ID 仍为 unresolved 时,只按既有间隔补读一次项目;不追加本地图层,也不把任务终态直接等同于画布投影终态。其他生成类型若要补同类保护,必须分别复现其权威回填时序后再改,不能用本条场景结论替代验证。
  • 验证:场景 workflow 用两个连续快照复现时序:第一个保留 scene / generating,第二个包含场景结果并把同一占位置为 idle。修复前只读一次并超时,修复后依次应用两个权威快照。
  • 关联:src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts、src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx、docs/technical/【技术方案】图片画布游戏场景生成链路-2026-08-04.md。

图片画布历史不能回退当前权威状态或复活后端已删素材

  • 现象:生成占位框移动后开始生成,撤销移动会把仍在运行的生成对象恢复成待生成状态;切换到 2K 或改变比例后撤销位置,旧占位框还可能把当前尺寸回退。上传图层落库后,普通移动撤销可能被提示“可能会使图片消失”并永久卡在栈顶;即使安全检查已放行,直接恢复旧图层快照也会丢失刚回填的资源关联。素材库后端删除关联素材后,更早的移动快照还可能把已删图层重新加入并自动保存;修改素材类型虽然界面提示撤销成功,刷新后却可能从仍指向新类型的 resource 回弹。无稳定 ID 的“修改图片”草稿也可能被 target-null 快照直接关闭。
  • 原因:内容消失安全检查和历史快照合并是两道独立边界。图层内容签名若严格比较 sourceAssetId、sourceResourceId 等延迟回填的内部关联 ID,会把同一媒体误判为替换;放行后若仍用目标快照整体覆盖同 ID 图层或占位框,又会回退当前权威关联、内容或尺寸。相同 dialog ID 直接恢复整个旧对话框快照还会覆盖当前 generating / 完成态;没有 ID 的 edit 草稿则根本不会进入存在性检查。外部素材删除不写画布历史,若不主动剪除包含关联图层的旧目标快照,target-only 图层会被当作正常撤销删除完整恢复。assetKind 的正式事实保存在项目 resource,历史只改内存字段而保留当前 resourceId 时无法跨刷新成立。
  • 处理:图层内容身份按对象存储 key、对象标识和媒体地址的稳定优先级比较,内部关联 ID 的补齐不参与内容消失判断。同 ID 图层以 current 为权威,只从历史覆盖 x、y、zIndex、groupId、assetKind、hidden、locked、flipX、flipY;current 的资源关联、内容、媒体、生成元数据、尺寸和标题全部保留。同 ID generation dialog 从 target 恢复 placeholder 的 x / y 和 active / inactive 槽位对应的 composerOpen,current 的 width / height / originalWidth / originalHeight、当前参数、任务生命周期、提示词、参考图和结果保持一致;edit 草稿使用基于来源图层的稳定 ID。current 中不存在对应 ID 时属于撤销完整删除,可从 target 全量恢复对象。素材库删除必须用 isLayerLinkedToAsset matcher 同步过滤 undo / redo 中所有包含关联图层的 entry,即使图层只存在于历史中也要过滤;普通画布删除不调用该接口。assetKind undo / redo 每次重新创建匹配恢复类型的正式 resource,并按 layer 请求版本只接受最新响应。即时生成结果在追加图层前捕获生成历史,自动适合视图不再压入另一条历史。
  • 验证:覆盖 idle 生成框移动后进入 generating 再撤销、上传图层异步回填 resourceId / sourceAssetId / sourceResourceId 后撤销移动仍保留当前关联值、切换到 2K 或改变比例后撤销位置仍保留当前占位尺寸、非活动生成框被激活并拖动后撤销可恢复原 active / inactive 打开状态、撤销完整删除可以全量恢复对象、即时生成后第一次撤销直接命中生成保护、阈值内指针抖动既不移动也不产生历史、素材库删除后旧 undo / redo 无法复活关联 layer、edit 草稿不会被 target-null 快照吞掉,以及 assetKind 撤销与乱序 resource 响应后刷新仍保持最终类型。
  • 关联:src/components/image-editor/ImageCanvasHistoryModel.ts、src/components/image-editor/useCanvasHistory.ts、src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts、src/components/image-editor/useImageCanvasStageInteractions.ts、docs/【图片画布】撤销范围与操作提示方案-2026-07-17.md。

图片画布生成器全体点不开先查卡住的临时交互状态

  • 现象:特定操作后,画布中已有生成器点击不再显示设定对话框,而且不是单个生成器坏掉;新建生成器或刷新页面后恢复。
  • 原因:旧生成器激活依赖全局交互状态;如果 Shift / 空格按住态因为窗口失焦漏掉 keyup,或“从画布选择参考图”等临时 picking / 菜单状态没有在激活旧生成器时清理,后续点击会被当成多选或选参考图而短路。
  • 处理:窗口 blur / 页面隐藏时释放 Shift 和空格按住态;激活已有 generation dialog 时同步清理参考图 picking、规格 / 参考菜单和右键菜单;active / inactive 生成器状态的 ref 与 React state 必须同事件周期同步。
  • 验证:npm run test -- src/components/image-editor/useCanvasGenerationDialogs.test.tsx src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx -- --runInBand,并跑 ImageCanvasEditorView.test.tsx 确认真实组件链路仍能激活生成器。
  • 关联:src/components/image-editor/useCanvasGenerationDialogs.ts、src/components/image-editor/useImageCanvasKeyboardShortcuts.ts、src/components/image-editor/ImageCanvasEditorView.tsx。

图片画布素材多时拖拽卡顿先查等距吸附候选规模

  • 现象:画布素材数量增加后,拖拽单个图层或生成占位框时 pointermove 明显卡顿,关闭或绕开吸附后体感恢复。
  • 原因:边缘 / 中心线吸附是线性扫描,但等距吸附如果对所有可吸附素材做两两配对,会在素材数量上来后进入 O(n²) 热路径。
  • 处理:保留边缘 / 中心线全量线性扫描;等距吸附先过滤跨轴相交素材,再只检查轴向邻近候选,不要为远处或不相交素材生成配对候选。
  • 验证:npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/ImageCanvasInteractionModel.test.ts,并在多素材画布拖拽时确认参考线仍能命中邻近图层且 pointermove 不再明显掉帧。
  • 关联:src/components/image-editor/ImageCanvasEditorModel.ts、src/components/image-editor/ImageCanvasInteractionModel.ts、docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md。

图片画布拖动卡顿先查 Stage 合帧和交互期自动保存

  • 现象与原因:多图层/帧拖动卡顿但网络正常,查 pointermove 重复渲染与本地同步持久化;远端 PATCH 防抖不阻止每次 serialize、JSON.stringify 和 sessionStorage。未移动 layer 保留引用也不等于完整子树不重渲染。
  • 处理:Stage 单一在途 RAF 合并同帧输入,只用最新坐标;pointerup/cancel flush 最后帧,卸载 cancel。越拖动阈值后暂停项目保存、session cache 和封面采样,结束保存最终布局。
  • 子树:按稳定 layer 对象浅比较 memo,父回调用 latest ref 稳定门面转发,既不击穿 memo 也不读旧闭包。小地图只保留 viewport controls 内单层合帧,不重复套 RAF。
  • 取证:使用多个独立 raster URL,测目标实际位移帧;空闲 RAF 心跳、重复 data URI SVG 或结束尾帧不能作为流畅证据。

图片编辑器生成长请求完成态必须由后端写入画布

  • 现象:画板角色形象等生成请求已经在服务端返回 200,OSS 中也已有 generated-character-drafts/.../image.png,但用户刷新或页面重载后仍看到旧生成卡片停在“生成中”。
  • 原因:生成是一次长 HTTP 请求,浏览器在请求完成前刷新或重新挂载时会丢失原页面的成功回调;如果完成态只靠前端回调把结果图层写回 editor_canvas.layers_json,服务端虽然已经创建 editor_project_resource / editor_asset,但布局里的 generation-dialog 仍可能停在 status="generating" 且没有 generatedLayerId。如果之后从素材库把同一私有素材加回画布,前端再次创建项目资源时若提交 signed URL / Data URL,还会触发 413,进一步阻断资源行绑定。
  • 处理:图片生成提交必须在有项目上下文时携带 canvasCompletion(生成器 dialogId、标题和占位框);api-server 生成成功并创建资源后,直接读取当前项目布局,只有当前布局仍存在对应生成器时才插入轻量结果图层、把生成器改回 idle 并写入 generatedLayerId,再沿用后端当前 viewport 保存 layout 并返回最新项目快照。前端只应用该快照刷新显示,不在加载时根据资源行推断完成态;有项目上下文但后端没有返回快照时也不得本地补结果图层。为已有 objectKey 的图层创建项目资源时,imageSrc 只提交 /<objectKey>,不要提交 signed URL / Data URL。
  • 验证:cargo test -p api-server editor_canvas_generation_completion --manifest-path server-rs/Cargo.toml 覆盖后端完成态写 layout;npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/services/image-editor/editorProjectClient.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx -- --runInBand 覆盖前端提交 canvasCompletion、应用后端快照、项目加载不推断完成态和 objectKey 资源创建不提交大 URL。
  • 关联:server-rs/crates/api-server/src/editor_project.rs、src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts、src/components/image-editor/useImageCanvasProjectPersistence.ts、docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md。

图片画布修改图层标签不能通过创建资源和换绑实现

  • 现象:用户只修改一个图层的素材类型,项目资源数量却增加且该图层的 resourceId 改变;如果修改请求完成前复制,原图层会换绑到新资源,副本仍引用旧资源,刷新后副本类型回退。
  • 原因:把资源 assetKind 同时当作共享默认值和布局实例标签,只能通过按类型查找 / 创建资源来模拟局部修改。异步响应只知道原 layerId,无法自动追踪期间复制出的新布局实例;layout 又没有独立覆盖字段,最终形成资源身份漂移和类型错位。
  • 处理:固定双层模型:editor_project_resource.asset_kind 是资源默认类型,editor_canvas_layer.asset_kind_override 是可空布局覆盖,effective 值为 override ?? resource default。图层标签动作只写 / 清除 override,保持资源行数量和 resourceId 不变;复制复用 resourceId 并复制 override。asset_kind_override 必须是追加在表末尾、默认 None 的 typed 字段,不能塞入 item_json;schema 同步 migration、表目录、bindings、DTO 和 canonical hash。
  • 验证:覆盖“修改标签不新增资源且不换 ID”“同资源两个图层可有不同 override”“复制保留 override 后可独立修改”“清除 override 恢复资源默认值”“刷新与 structured round-trip 不丢覆盖”,并运行 npm run spacetime:generate、npm run check:spacetime-schema、定向 Rust / API / 前端测试。
  • 关联:server-rs/crates/spacetime-module/src/editor_project_storage.rs、server-rs/crates/spacetime-module/src/migration.rs、src/components/image-editor/useImageCanvasProjectPersistence.ts、src/services/image-editor/editorProjectClient.ts。

图片画布发布入口 429 先查自动保存 PATCH 并发

  • 现象:发布域名访问画板时出现短时间密集 429,Nginx access log 中 PATCH /api/editor/projects/<projectId>、生成接口和资料接口混杂,429 行常见 request_time=0.000、upstream_status=-,error log 写 limiting connections by zone "genarrative_api_conn"。
  • 原因:这类 429 是入口 Nginx limit_conn 在转发前拒绝,不是 api-server、SpacetimeDB、worker 或 VectorEngine 的业务 429。画布自动保存如果只有防抖、没有 in-flight 串行保护,慢 PATCH /api/editor/projects/{projectId} 未完成时,拖拽生成器、资源回填和后续状态变化会继续发起新的保存请求,同一客户端连接数被长请求撑满后触发入口连接限流。
  • 处理:不要先放大 Nginx 限流或把错误归给生成 provider;先看 access log 的 upstream_status / request_time 和 error log 的 limit_conn zone,再查前端保存路径。useImageCanvasProjectPersistence 中自动保存和资源创建后的布局保存必须共用串行队列:同一时刻只允许一个 saveEditorProjectLayout in-flight,期间新快照覆盖旧待保存快照,当前保存结束后只发送最新一次。
  • 验证:npm run test -- src/components/image-editor/useImageCanvasProjectPersistence.test.tsx -t "serializes project layout saves" --reporter verbose 应覆盖慢保存期间不启动第二个 PATCH,首个保存完成后只发送最新待保存快照;排查发布现场时 429 行应从 upstream_status=- / Nginx limit_conn 收敛。
  • 关联:src/components/image-editor/useImageCanvasProjectPersistence.ts、src/components/image-editor/useImageCanvasProjectPersistence.test.tsx、docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md。

图片编辑器生成占位图在生成中也要使用最新拖拽位置

  • 现象:用户在图片编辑器里提交生成后继续拖动画布占位图,预览框可以移动,但生成完成后的真实图片仍落回提交瞬间的旧位置。
  • 原因:生成提交函数闭包里保存了旧的 dialog.placeholder 快照;如果完成回包仍用这个快照创建图层,就会丢失生成中期间的拖拽坐标。若 handleGenerationFramePointerDown 又按 status === 'generating' 拦截,则生成中占位图完全不能拖动。
  • 处理:生成占位图的 pointer down 不因 generating 禁止;普通图片、规范图、角色图和图标素材回包创建图层时,都从当前 generateDialogRef.current.placeholder 读取最新占位位置,失败后保留的占位图也继续走同一拖拽链路。
  • 验证:npm test -- src/components/image-editor/ImageCanvasEditorView.test.tsx -t "keeps the generation placeholder draggable while the image is generating"。
  • 关联:src/components/image-editor/ImageCanvasEditorView.tsx、src/components/image-editor/ImageCanvasEditorView.test.tsx、docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md。

图片画布快速编辑完成必须按目标图层回写

  • 现象:图片快速编辑任务成功后,刷新页面素材库能看到新图,但画布上的源图没有替换。
  • 原因:/api/editor/images/edits 只保存生成图、项目资源和素材;没有 canvasCompletion 时不会写 editor_canvas.layers_json。sourceResourceId 只能表示溯源,同一资源可出现在多个图层,不能用它来决定替换哪一层。
  • 处理:图片快速编辑请求必须传 targetLayerId;后端在没有 canvasCompletion 的快速编辑完成分支里,用目标 layer id 和生成资源写回项目 layout。
  • 验证:npm run test -- src/services/image-editor/editorProjectClient.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx -- --runInBand;后端验证至少覆盖 editor_image_edit_request_omits_price_mud_points 和 editor_image_edit_can_complete_by_replacing_target_layer。
  • 关联:src/services/image-editor/editorProjectClient.ts、src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts、server-rs/crates/api-server/src/editor_project.rs。

项目画布跳转不要先写无参画布路由

  • 现象:从 /creation 最近项目或 /project 项目卡进入画布时,浏览器先进入 /editor/canvas,随后再进入 /editor/canvas?projectid=xxx,导致返回来源页需要点两次。
  • 原因:App 传给平台壳的 setSelectionStage 会按 stage 自动 pushAppHistoryPath(resolvePathForSelectionStage(stage));如果项目入口先 setSelectionStage('image-editor') 再写项目 URL,就会把无参数画布路由塞入 history。
  • 处理:项目入口必须先写入最终 /editor/canvas?projectid=xxx,再切 image-editor 阶段;App 的 stage setter 在当前位置已经解析为 image-editor 时不要再补写基础画布路由。
  • 验证:npm run test -- src/App.test.tsx;浏览器中从最近项目或项目页打开项目后,后退一次应直接回到 /creation 或 /project。
  • 关联:src/App.tsx、src/components/platform-entry/PlatformEntryFlowShellImpl.tsx、docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md。

画布生成完成态不能被旧 autosave 覆盖

  • 现象:release 外部生成 worker 补跑完成后,生成图已进入素材库或项目资源,但画布生成器仍显示 generating;刷新后可能仍看到历史生成框卡住。
  • 原因:画布前端在提交生成后会把 generating layout 放入 450ms 自动保存队列;worker 完成后后端会写入 idle + generatedLayerId + 生成层,但旧的 pending / in-flight layout save 可能晚到并覆盖完成态。另有历史 inline 请求在 api-server 重启时只留下前端已保存的 generating 框,没有终态任务或生成资源。
  • 处理:前端 applyProjectSnapshot 必须取消 pending layout save,并跳过一次由后端快照恢复触发的 autosave;后端 save_editor_project_layout 要保护已完成的 generation dialog,如果传入旧 generating 且无 generatedLayerId,而当前 layout 已有同一 dialog 的完成态,则保留完成态和生成层。线上脏数据只在确认无任务 / 无资源时标成 failed 并保留原 prompt 供用户重试。
  • 验证:npm run test -- src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx;cargo test -p spacetime-module --manifest-path server-rs/Cargo.toml editor_project_storage --lib;release 排障用 list_editor_projects_and_return / get_editor_project_and_return 查 generation-dialog 状态,不要只看素材库。
  • 关联:src/components/image-editor/useImageCanvasProjectPersistence.ts、server-rs/crates/spacetime-module/src/editor_project_storage.rs、server-rs/crates/api-server/src/external_generation_worker.rs。

旧 scope 的卡死请求不能占住新资源画布队列(2026-07-30)

  • 现象:用户在 dependency 布局保存尚未返回时切到 type 或另一个项目,新 scope 已完成读取且拖动已进入队列,但因为全局活动请求引用仍指向旧 scope,新的保存会无限等待旧请求结束。
  • 原因:epoch 只阻止迟到响应覆盖新状态,不会自动释放前端单写者槽;把“不能取消已经发出的请求”误写成“所有后续 scope 都必须等待它”,会把一个网络或 IPC 卡死扩大到整个 Hook 生命周期。
  • 处理:FIFO 和单写者只约束同一 projectPath + projectId + mode scope。切换 scope 或卸载时立即放弃旧活动槽并清空旧队列,旧 Promise 仍可在后台结束,但其结果由 epoch 丢弃,finally 也只能按意图身份清理自己,不能清掉新 scope 的活动请求。后端继续用 expectedProjectId、CAS revision 和系统锁仲裁已经发出的旧写入。同 scope 在途 CAS 不取消;其后相同资源与 section 的排队拖动只保留最后坐标,避免连续输入造成无界队列。
  • 验证:让旧 mode 更新 Promise 永不先 resolve,切换 mode 后应立即发送并完成新 mode CAS;随后再 resolve 旧请求,新布局、saving 状态和请求数均不得变化。另以百次同资源拖动证明在途请求之后只追加一笔、坐标为最后一次输入。
  • 关联:apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts、apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts。

资源协调冲突清除排队拖动时不能静默重试(2026-07-31)

  • 现象:资源协调 CAS 在途期间,用户拖动资源形成排队 manual intent;协调请求随后 conflict 并基于权威 revision 自动重试成功,布局正确保留另一窗口结果,但界面没有提示本地拖动已经被丢弃。
  • 原因:冲突分支虽然清除了当前 scope 的全部 manual intent,却只按“当前 intent 是否为 manual”或“资源协调是否停止重试”决定提示;当前 intent 为 resources 且可重试时,排队拖动的丢弃事实没有进入提示条件。
  • 处理:过滤队列前记录本次是否实际清除了 manual intent。只要当前 manual 发生冲突或清除了任何排队 manual,就必须提示用户重新拖动;冲突前坐标不得自动重放,后续资源协调继续使用冲突响应的权威 revision,并且成功响应不能静默清除提示。
  • 验证:定向 Hook 测试固定“resource sync revision 1 在途、manual 排队、权威 revision 2 conflict、resource retry 成功”时序,断言 retry 使用 revision 2、权威坐标保留、旧 manual 不重放且 notice 仍存在。
  • 关联:apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts、apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts。

权威画布快照不能清掉本地待保存或在途布局(2026-08-03)

  • 现象:用户拖动、缩放、改层序、背景色或 viewport 后,生成完成回包立即覆盖画布;450ms 防抖尚未触发或布局保存仍在途时,编辑静默丢失,undo 也可能被生成保护项阻断。
  • 原因:服务端 revision 只能排序已提交事实,本地未落库布局没有 revision;直接清空 pending save 并整体应用权威快照等同于把“服务端更新更晚”误判成“服务端知道本地编辑”。
  • 处理:保留同项目最新本地 dirty snapshot,权威回包先更新资源和生成终态,再按稳定 item ID 合并本地布局字段并基于新 revision 保存。旧权威项在新快照缺失表示后端删除,不能从 pending 或在途旧输入复活;新权威项必须合入,本地删除的旧项不能从权威回包复活。
  • 生成器边界:composerOpen 与 status / generatedLayerId / errorMessage 一样属于后端生命周期事实;生成完成快照要求保持面板关闭时,不得被本地在途快照重新展开。提示词、参数和占位位置等本地布局编辑继续保留。集成测试夹具必须模拟后端真实完成快照:既有布局保持原位,完成结果层追加到末尾。同项目权威刷新还必须保留仍有效的单选、多选、生成占位选择或空选,只过滤已删除目标,不得无条件降成第一张图层的单选;首次载入 / 项目切换才设置默认选择。不要只跑 persistence Hook 单测,必须同时运行图片画布生成集成测试,覆盖完成后面板关闭、显式选择结果、背景清选和合并后 CAS 保存。
  • 验证:分别覆盖防抖 pending、真实在途成功与 409、后端新增、后端删除、本地删除、viewport、背景色和生成面板完成态;运行 npm run test -- src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx。

新资源自动聚焦不能把投影、布局和 DOM 当成同一时刻(2026-08-05)

  • 现象:保存回调已经带回 manifest,但新卡片可能尚无 dependency/type 坐标或尚未提交 DOM;立即选择会得到空画布、错误滚动,迟到回调还会抢走用户后来选择的资源。
  • 原因:把 durable commit、资源投影、关系图 ready、两份布局协调和 React DOM commit 压成一个“保存成功”布尔值,缺少保存尝试身份和用户意图 generation。
  • 处理:保存开始记录 saveAttemptId + sessionId + draftId + commitId + focusGeneration。自动定位依次等待资源投影存在、dependency/type 两份布局 settled 且都有位置、搜索条件可见和稳定 data-resource-id DOM 存在;按 commitId 只执行一次。切项目、切 mode、改选择/搜索、取消或开始新 flow 都推进 generation;迟到结果仍可合并权威 manifest,但不能改变选择。隐藏时保留搜索,只由显式“清除搜索并定位”建立新 generation。
  • 验证:覆盖 manifest 已更新但布局未完成、DOM 后只聚焦一次、搜索隐藏、保存中切项目/改选择和连续保存;测试不得用 reload 或重开项目绕过阶段边界。

非整除 nearest 会让逻辑像素块宽窄不一(2026-08-10)

  • 现象:像素规整后的图片虽然保持了源图宽高,放大观察却能看到相邻逻辑块占用的物理列数或行数不同,表现为部分块更宽、部分块更窄;整数倍样例看起来正常,换一张网格数不能整除输入尺寸的图才复现。
  • 原因:逻辑图宽高为检测后的列数、行数。把 C × R 的逻辑图用 nearest 恢复到 W × H 时,只要 W % C != 0 或 H % R != 0,目标栅格就只能在不同逻辑像素间分配 floor / ceil 数量的列或行;nearest 能避免混色,却不能让非整数缩放后的块严格等大。只用 128 × 128 → 64 × 64 → 128 × 128 这类整数倍测试会掩盖问题。
  • 处理:采样仍是一格一像素;编码前只允许横纵同一整数 N 的 nearest 放大,N 取最接近规整输入尺寸的正整数,禁止非整数拉回精确 W × H。成品宽高比等于逻辑图,尺寸接近但不保证等于源图或占位。普通图片和角色的前置 Lanczos 交付尺寸归一仍用于确定检测输入;平底网格源与透明 RGBA 源仍必须同尺寸。
  • 验证:整数倍样例与非整除样例都不得出现宽窄不一的逻辑块;响应、project resource、账号素材和结果 layer 都记录最终 PNG 实际尺寸,且只持久化一个最终 PNG,没有未放大逻辑图、输入尺寸恢复版、诊断图或额外资源。失败降级用例继续验证 Alpha / 交付尺寸守卫。
  • 关联:server-rs/crates/platform-image/src/pixel_art_snapper.rs、server-rs/crates/api-server/src/editor_project.rs、docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md。

无限画布延迟草稿与零位移不能制造新状态(2026-08-11)

  • 现象:r5 的 loadDraft 比 r6 更晚回包时会把草稿回退;pointerdown 后没有任何移动,pointerup 仍增加一条空 undo 并触发 CAS 保存;Tauri 自行维护 Shift toggle 后,Shift 单击唯一选中图层会意外清空选择。同一 canvas.failed 视图还可能把草稿保存或提交故障显示成生成重试。
  • 原因:延迟回包只与发起时 revision 比较,没有在落地时复核当前最高 revision;指针按下就 capture history,而不是等首次真实几何变化;宿主复制了共享 selection 规则;失败状态没有携带发生故障的 operation 类别。
  • 处理:generation progress、保存队列、生成/提交回包和延迟 loadDraft 统一用当前 scope、触发最低 revision 与回包当下草稿的单调门禁;同 revision 只允许完整相等回包。Tauri 指针和键盘选择复用 resolveLayerPointerSelection。pointerdown 只冻结快照,首次真实 move/resize/pan 才 capture 一次;零位移、未变选择和锁定图层不增加 undo、documentVersion 或草稿保存。
  • 失败边界:canvas.failed 必须携带 generation / draft-save / asset-commit / recovery / cancellation,只有 generation 失败显示“返回修改/重新确认”。保存/CAS 只重试或重载,提交/恢复只安全恢复或对账,取消故障只保留草稿继续编辑;初始恢复失败也不得进入生成重试。
  • 验证:用 deferred Promise 覆盖 r5/r6 逆序、保存与 progress 交错和 scope 切换;同时覆盖 Shift 单选自身、多选拖动、指针完整序列、零位移、首次有效移动只一条 history,以及五类失败的可访问名称与按钮集。

共享画布框选需要识别 world 的真实后代命中

  • 现象:共享 CanvasWorld 在 world 外层增加 .genarrative-image-canvas__world-content 后,点击或拖拽空白画布时事件 target 是内层 div;框选逻辑若只检查外层 world 本身,就会只清除焦点而不创建选框。
  • 原因:viewport 上的 pointer 事件通过冒泡接收,event.currentTarget 是 viewport,空白区域的 event.target 可能是 world 的任意后代,不保证命中外层元素。
  • 处理:框选命中判断使用 Element.closest('.genarrative-image-canvas__world'),并保留 viewport 自身命中路径;回归测试通过真实 CanvasWorld DOM 的 pointerdown 冒泡覆盖内层 world content。
  • 验证:src/components/image-editor/useImageCanvasStageInteractions.test.tsx 覆盖内层 world content 命中,定向交互测试通过。
  • 关联:packages/image-canvas-react/src/useImageCanvasStageInteractions.ts、packages/image-canvas-react/src/CanvasWorld.tsx。

通用 UI 与交互

推理档保存去重必须以成功落盘为准

  • 机制:原生 range 长按会重复 change;若按“已发出请求”去重,落盘失败后同档无法重试。只以最近成功落盘值作为 persistedEffortRef 基线。
  • 卸载:菜单关闭/宿主卸载时,从 ref 提交最后预览值,不更新已卸载组件 state。
  • 焦点:forced-colors 会丢 thumb 阴影,不能只靠 shadow 表示焦点,应提供系统色 outline。输入区状态与失败用 DOM 外 portal 去重,清空后允许再次提示,不占工具栏第二行。

2026-10-05 页面返回兜底不能新增历史条目

  • 普通链接整页打开的条目可能没有应用导航标记;此时返回若调用 pushAppHistoryPath,再点返回就会退回原深链,形成循环。无应用内历史的返回应使用 replaceAppHistoryPath,有历史时保留原生后退。
  • 创作者主页顶部资料不自链接;详情和关系列表中的用户链接保留。具体兜底目标见创作者专题合同;验证须覆盖真实浏览器连续返回及列表分页/滚动恢复,不能只检查 history.back() 被调用。

2026-10-03 后台异步回填被「进项目」覆盖:AI 项目名只在兜底名仍成立时改

  • 现象(Issue 599):首页建项把 AI 命名改成「与建项并行、结果后台回填」后,偶尔工作台标题停在兜底名(GameAgent 项目 <短 id>),而磁盘 manifest 已经是 AI 名字。
  • 原因:改名落盘很快,但写「当前项目上下文」的那次 setCurrentProjectContext 被 enterProjectDevelopment 随后的兜底上下文覆盖——AI 比进项目更快时,回填写入时上下文里还没有这个项目(current 为 null 或仍是上一个项目)。同一原因下 refreshRecentWorkspace 也会被丢弃:最近项目行只在 recentWorkspacesRef 已包含该路径时才更新状态,而路径是进项目时才登记的。
  • 处理(现行口径):后台改名任务里,「写项目上下文 + 重检最近项目行」这两步必须等「建项主体收尾」(进项目成功、失败或让位于别的项目)的信号再执行;改名本身照旧立即落盘。回归用 promise 闸门卡住 get_local_game_preview_status 复现顺序,不用 sleep。
  • 判据/取证:npx vitest run apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx 的「keeps the AI name when the naming result lands before the project is entered」(去掉等待即复现兜底名覆盖);appSurface 的「syncs the workbench title and recent project list with the AI name after creation」。
  • 边界:条件改名的判据必须由宿主校验(项目 ID + 当前名称仍是兜底名),前端只转述 expectedProjectId / expectedName;用户已手动改名时宿主返回 renamed: false,回填整体跳过、绝不覆盖用户输入。
  • 关联:apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts、apps/ai-game-creator-shell/src-tauri/src/commands.rs。

2026-10-02 AGC 页面在自绘标题栏外壳里自己算 100vh:底部被裁而且没得滚

  • 现象:帮助页(使用指南 / 联系客服 / 更新日志)在矮窗口里底部卡片看不到,把窗口拉高才出现;外壳 .launcher-main { overflow: hidden } 之下没有任何可滚动祖先,页面既滚不动也裁得干净。首页在通知横幅出现时用 h-[calc(100vh-32px)],同样把窗口高度当成了舞台高度。
  • 原因:AGC 桌面外壳是自绘标题栏(--window-chrome-height;窗口 100vh=800 时舞台只有 750),页面根节点写 100vh / 100dvh / calc(100vh - Npx) 就比真实舞台高一整个标题栏,差额被外壳裁掉;横幅是 .launcher-main 里的真实行,再写 -32px 等于重复扣一次。帮助页还没有内层滚动容器,连「内容超高就在内部滚动」这条兜底也不存在。
  • 处理(现行口径):页面高度只由外壳分配——apps/ai-game-creator-shell/src/styles.css 里 .launcher-main:has(<页面钩子>) 是纵向 flex 列(height: 100dvh,窗口外壳命中 height: 100% 时贴合真实舞台),.launcher-main > <页面根节点> 统一 flex: 1 1 auto; height: auto; min-height: 0,帮助页这类没有内层滚动容器的再加 overflow-y: auto。页面根节点一律不再写 100vh / 100dvh / calc(100vh - Npx);有横幅就靠 flex 自动少一份,不要手算偏移。
  • 验证:真机判据是 Vite + Chromium 量页面根节点是否正好等于 .window-chrome__content 的高度(1440x800 / 1440x560 / 390x844 / 390x560,带与不带横幅),帮助页应可滚动到底。

2026-09-24 模型输出的围栏会粘在正文行里:聊天 Markdown 必须先归一化再解析

  • 现象:AGC 对话里代码块解析错位——引言行被当成代码渲染(…实现细节(game.js):```js),或者代码块收不住、把后面的正文一起吞进去(`… return centerOn(projection); }````)。文本本身「看起来没问题」,容易被当成渲染器坏了。
  • 原因:CommonMark 只认整行的围栏(最多 3 个空格缩进)。模型经常把 直接粘在上一行末尾,那个 退化成行内文本:开场围栏不成立(后面的正文被当成代码)、收场围栏不生效(代码块不闭合,吞掉剩余内容)。ChatMarkdownMessage 原先只做空行压缩,没有这一步归一化。
  • 处理(现行口径):normalizeMarkdownFences 在解析前把「围栏前是非空白字符、围栏到行尾只剩语言标识(可空)」的行拆成两行。判据刻意收窄:整行 / 缩进围栏、行内代码(单个反引号)、代码里出现的 (`const s = "";)、引用块 / 列表项开头的合法围栏(> js`、`- js)以及同一行里出现第二段围栏串的行内代码(``文本 ```x``` ``)都不动——后三者拆开只会把围栏从引用块 / 列表项里挪出来,或者凭空造出一个开场围栏。归一化对 preserveBlankLines`(文件预览)同样生效。
  • 同时:块级 pre 与块内 code 都要给 whitespace-pre-wrap + break-words(两处各写过 white-space,只改一处不换行),否则窄面板里长行会把消息拉宽、顶出横向滚动条。
  • 验证:npx vitest run apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx(去掉归一化或换行类名即红);真实 Chromium 夹具里长代码行 horizontalOverflow: false、粘住的开场 / 收场围栏都渲染成正确结构。

策划回复的重复终态不能重新启动伪流式

策划 Runtime 会通过状态事件与命令返回交付同一份最终视图。若前端清空临时正文后再拿“最后一条非用户历史消息”回填动画,就会出现正式回复旁又播放一遍、播放后消失的假重试。正文应按 messageId 保存显示进度,与正式消息共用一个气泡;请求完成不清动画,不延迟正式业务状态。Provider 自动重试复用消息 ID 并发送空文本,只允许重置未持久化的该条回复。正文、工具状态和 reasoning 分开;事件与异步命令收尾均检查项目及活动回合,旧请求不能覆盖新回合。详见 AGC 实施计划。

2026-09-23 弹窗打开时自绘标题栏的最小化 / 最大化 / 关闭静默失效

  • 现象:AGC 打开「发布到游戏广场」面板(以及其它任何弹窗)后,右上角三个窗口按钮点了没有任何反应,拖拽标题栏也不能移动窗口;关掉弹窗立刻恢复。标题栏看着完全正常,遮罩也明显只压住了下面的工作区,所以很容易误判成「按钮自己坏了」或 Tauri 窗口 API 挂了。
  • 原因:标题栏在模态之外,但它是窗口边框。ThemedModal 用的 focus-trap-react 在 document 捕获阶段监听 mousedown/touchstart/click:模态外的点击一律 preventDefault(),click 还会 stopImmediatePropagation()。React 的监听挂在 document 内的根容器上,捕获阶段就被掐掉的 click 永远到不了 React,于是既不报错也不执行 —— 与「焦点陷阱吞掉模态外点击」是同一类问题(见 2026-09-20 发布面板焦点陷阱那条)。另有一条独立的同类缺陷:.app-update-overlay 用 inset: 0,把标题栏真的盖住了,更新弹窗期间按钮被遮罩挡住。
  • 处理(现行口径):① 全屏弹层一律从标题栏下方开始(top: var(--window-chrome-height)),不得用 inset: 0 盖住标题栏;② ThemedModal 的焦点陷阱用 allowOutsideClick 只放行落在 [data-window-chrome-bar] 内的目标,工作区内容点击继续被拦;③ 新增全屏弹层时把类名补进 apps/ai-game-creator-shell/tests/windowChromeOverlayContract.test.ts 的清单。
  • 验证:npx vitest run apps/ai-game-creator-shell/tests/themedModal.test.tsx apps/ai-game-creator-shell/tests/WindowChrome.test.tsx apps/ai-game-creator-shell/tests/windowChromeOverlayContract.test.ts(标题栏点击放行、工作区点击仍被拦、7 个全屏弹层都在标题栏下方);两个新增用例去掉修复后确实失败,确认能守住这条约定。
  • 关联:apps/ai-game-creator-shell/src/components/modal/ThemedModal.tsx、apps/ai-game-creator-shell/src/components/WindowChrome.tsx、apps/ai-game-creator-shell/src/styles.css。

最近项目一次失败会被钉成终态

  • 现象:AGC 卡住一次后,项目列表每一行都显示「检查失败 + 待识别」,首页「最近项目」变成「暂无最近项目」;现场在后端恢复后逐条复跑 inspect_local_project_directory(8 个项目)全部 0ms 成功,界面仍然全红(issue #490)。
  • 原因:单次检查的 5s 超时被吞成 null 写入状态表,而刷新用的增量投影是 next[path] = current[path] ?? null,把失败结果原样搬进下一轮;effect 只依赖列表与刷新计数器,既没有重试也没有 focus/visibility 重查。于是一次抖动会让整张列表永久停在失败态,首页同时被 canOpen 过滤清空。
  • 处理:失败就地重试一次(300ms);失败结果不进新投影;一轮仍有可重试失败时按 15s / 45s / 120s 重跑整表(上限 3 次,失败集合变化即重置预算)。提权/权限类失败(DACL、权限、error 5、安全对象不属于当前用户、特权、1300、AGC ACL 提权修复未成功)按不可重试处理,在用户主动打开/新建项目或重命名刷新之前跳过——否则「提权被拒 → 300ms 后重试」会自己驱动 UAC 反复弹窗。
  • 验证:apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx 的三条用例(「单次失败就地重试」「失败不跨轮保留」「提权类失败不重试」),改前代码上前两条必挂;tests/appSurface/home.suite.ts 的失败态断言改为等待最终状态。
  • 关联:apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts、src-tauri/src/config.rs。

生成草稿与异步展示边界必须按身份隔离

非模态生成浮层切换占位时按 draftId 分实例,卸载保留未提交/失败草稿,成功提交不再复活草稿;旧项目占位不存在时丢弃其保存回调。失败重试保留原请求输入和引用身份,引用失效不能静默过滤;修改已绑定输入须明确另起请求,不伪装成原请求重试。

聊天真实发送时间按相同用户 itemId 与正式条目合并,不能被启动应答后的观测时间覆盖。Thread Manager 生命周期事件透传已有 userItemId,使仅历史回读、live 为空的回合仍可精确补终态时间;不得按历史尾项或时间近似猜归属。布局整理仅可合并队尾同范围意图,不能越过中间手动写入;拖动预览和保存均冻结按下时的相应坐标基准。

2026-09-17 AGC 输入盒的「推理档」弹层被祖先裁切:要放开裁切而不是挪弹层

  • 现象:窄窗口下(视口 ≤1000px 时右侧对话面板只有 280px 宽)点开输入盒右下角的「推理档」,弹层是个空盒子:档位文字(默认 / 低 / 中 / 高 / 最高)整片看不见,只剩一个方框。
  • 成因:推理档是控制排里最靠左的弹层锚点,.conversation-model-menu 默认 right: 0 贴触发钮右缘向左展开;触发钮右边还压着模型选择、语音、发送三颗钮,所以 150px 宽的弹层在 280px 面板里会伸到面板左侧 42px 之外。.game-workbench-chat、.project-supervisor-surface.is-direct-codex、.project-supervisor-conversation 三层各自的 overflow: hidden 沿自己的溢出边界裁掉它,而档位文字起点才 14px(面板左内边距 5px + 按钮左内边距 9px),正好落在被裁掉的那半边。
  • 处理(用户指定口径):不挪弹层位置——只让 direct-codex 那三层不再裁切:.game-workbench-chat:has(.project-supervisor-composer.is-direct-codex)、.game-workbench-chat .project-supervisor-surface.is-direct-codex、.game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-conversation 三条 overflow: visible。弹层的 right: 0、尺寸和触发钮锚点全不变,只是允许它盖到左侧资源面板上完整显示。消息列表自带 overflow-y: auto(另一轴按规范计算为 auto),消息内容仍由列表自身裁剪。
  • 易错点:① 把弹层改成 left: 0 或往右挪也能让它可见,但那是改变展开方向,弹层会跑到触发钮右边(用户明确否决);② 只放开最外层聊天列不够——surface 与 conversation 各自都会裁,三层必须同时放开;③ 只按宽度比大小会误判:280px 面板里控制排本身也超出(发送钮右侧溢出 22px,被窗口右缘吃掉),那不是本条的原因,别顺手去改控制排布局。
  • 验证:apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts 的 keeps the landscape workbench edge-to-edge with internal chat scrolling 钉住三条 override 声明在场(删掉任一条即红)。真机几何用 playwright-cli 打开一份只含真实 styles.css 与真实 composer DOM 的最小复现页实测(视口 1000×700、面板 280px):弹层 rect 修复前后都是 [-42, 108](位置未动),elementFromPoint 的命中区间从修复前的 [2, 108] 变成整块;档位文字在截图中完整可见。
  • 关联:apps/ai-game-creator-shell/src/styles.css(面板纵向布局(2026-07 Codex 风格改造) 区块之后)、apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts。

2026-09-17 资源卡「内容跟着边框动」与「模型缩略图被拉伸」是两条不同的几何陷阱

  • 内容跟着状态边框位移:卡片底态是 border: 0,悬停 / 选中才加 1px 边框;卡片是 box-sizing: border-box,而卡面(.game-resource-card-visual)与角标都是 position: absolute; inset: 0(包含块 = padding box)⇒ 状态一切换,内容盒四边各被吃掉 1px,卡面与角标整体位移并缩小 2px。修法:底态写成 border: 1px solid transparent;,状态只点亮 border-color;契约用例改成断言「资源卡规则里不得出现非 1px 的 border / border-width」。
  • 模型缩略图看起来被拉伸 / 被裁:三个原因叠在一起 —— ① 缩略图渲染器的 PerspectiveCamera 是单例复用件,aspect 默认 1 且从没更新,方形投影被塞进宽扁缓冲;② 卡面里 width/height: 100% 的图片挂在 place-items: center 的网格里,网格项高度会退化成"按内容定高"(百分比高度解析成 auto),图片按自然比例长过卡片、被 overflow: hidden 裁掉,看起来就像被拉伸;③ 栏目画布用 transform: scale(var(--resource-section-zoom)) 放大(真机 1.5 倍),而 ResizeObserver / offsetWidth 只看布局盒,缩放变化既不触发 observer,按布局尺寸 1:1 渲染的图也会被放大到发虚。修法:渲染前设 camera.aspect;图片改 position: absolute; inset: 0 + object-fit: contain;渲染尺寸取 offsetWidth/offsetHeight × 2 超采样(上限 1024),并把实际渲染尺寸暴露到 data-model-render-size 方便排障。

窗口 Context 发布必须稳定回调与快照身份

  • 现象与原因:首页或工作台空闲时持续 Maximum update depth exceeded,CPU、内存和日志上涨。发布活动项目的 effect 依赖每次渲染新建的 openProject 等业务回调,cleanup/主体再更新 WindowChrome,形成 Context 发布与重渲染循环;不能直接归因于模板数量或滚轮频率。缺少组件栈时可在 console.error 包装中捕获同栈调用位置。
  • 处理:转发入口保持稳定,在提交阶段更新实际处理器 ref;发布数据变化与卸载清理分开。WindowChrome 的 context value 用 useMemo,活动回合快照只在内容变化时更新,初始签名与空数组一致,无原生读取器时只发布一次空状态。
  • 异步边界:停用、重新启用或切换读取器都须使旧请求失效,迟到结果不能覆盖新快照;首次异步返回空数组也不能无故换引用。
  • 验证与入口:组合真实窗口 Provider 与工作台消费者,用有界发布次数防止测试失控;控制 Promise 完成时机并核对禁用后引用,不能以“初始数组已为空”证明请求结束。相关实现为 WorkspaceLauncher.tsx、useHomeProjectCreation.ts、WindowChrome.tsx 和 features/agent-runtime/directActiveTurns.ts。

2026-09-02 Tauri 事件桥在浏览器预览中必须 fail-safe

  • 现象:Vitest/jsdom 挂载 AGC 客户端时,错误报告通知调用 @tauri-apps/api/event.listen,因缺少 window.__TAURI_INTERNALS__ 产生未处理拒绝;测试断言虽通过,CI 仍以 unhandled errors 失败。
  • 原因:错误报告订阅是非阻塞唤醒通道,不能假定所有渲染环境都已初始化 Tauri IPC;模块级 listen 在 API 调用前就会访问 transformCallback,仅在调用方包一层 .then 无法消除该环境差异。
  • 处理:订阅桥先复用 window.__TAURI__.event.listen(含 globalTauri/native shim),其次仅在 __TAURI_INTERNALS__ 存在时调用模块 API;浏览器预览或订阅失败统一返回 no-op,并在消费层收口 rejection。错误快照读取、焦点和可见性刷新仍是权威路径。
  • 验证:errorReporting.test.ts 覆盖无 Tauri 环境无未处理拒绝;appSurface.test.ts 全部 385 条用例通过且无 Vitest unhandled errors。

素材保存成功不等于迟到结果仍有权抢占当前焦点

  • 现象:用户等待生成/保存时切到另一个项目、run、另一份素材草稿,或主动选择其它资源、修改搜索条件;旧请求完成后界面却切回旧画布、清空筛选并自动选中新资源。
  • 原因:异步回调只检查“请求成功”或捕获的旧 isMounted/projectId,没有绑定中央状态 session、draft/intent、selection epoch 和 query epoch;manifest 投影这一数据事实又被错误地与“当前应自动聚焦”的用户意图合并处理。
  • 处理:保存开始捕获 projectPath + projectId + centerKind + sessionId + draftId + intent + selectionEpoch + queryEpoch,响应时从当前 ref/store 完整复核。manifest 可以按精确项目身份更新当前上下文或后台缓存,但自动切状态、选择、滚动和聚焦必须等当前 mode 布局 ready 且全部焦点守卫仍相等。新资源被搜索/筛选隐藏时保留条件与选择,提示“新资源已保存,当前筛选条件下不可见”,只提供显式清除/定位动作。
  • 验证:使用 deferred commit/layout Promise,依次在请求后切项目、切 run/overview、新开 session、改选择和改筛选;断言 manifest 只更新对应项目,新资源仍进入投影/布局,但所有失效守卫都不切中央状态、不改选择、不清查询。条件未变化且资源可见时才自动定位。
  • 关联:docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md、docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md。

素材选择弹窗的打开初始化必须先于用户点击

  • 现象:版本级资源替换用例偶发在确认后读不到替换关系;本地单独运行通过,完整前端回归或 CI 中失败。
  • 原因:弹窗打开时用普通 useEffect 初始化选择,用户点击素材可能先提交 setSelection,随后初始化 effect 又把选择清空,确认收到空数组而不会发起写入。
  • 处理:初始化查询、分类和选择改用 useLayoutEffect,仍只依赖 open,在首次绘制前完成打开态初始化,避免父级普通重渲染重置用户选择。
  • 验证:npm test -- apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx --reporter=dot;应通过该文件全部 16 条用例。

陶泥儿精选顺序分列不要用 multi-column 或共享 Grid 行高

  • 现象:/creation 桌面端主内容区明明能放下三张卡,首行却只出现一到两张,后续素材提前回到左侧下一段;活动卡与普通素材高度差较大时尤其明显。
  • 原因:column-count 按纵向文章列流入并平衡,三张卡可排成 2 + 1 + 0 列;标准 CSS Grid 虽会横向先填三张,但整行共用最高卡行轨,短卡下会留下高度差。align-items: start 只是不拉伸短卡,不能消除行轨空白;grid-auto-flow: dense 也不能填单个网格项内的剩余高度。
  • 处理:保留平面 DOM 和 .creation-landing__asset-waterfall 旧类名,按当前列数将第 index 张显式放入 index % columns 列;三列时第 1/2/3 张分别进入三列,第 4/5/6 张再分别接到三列下方。列数以容器实际宽度和 288px 首选最小列宽计算,不以 viewport 硬切;先设目标卡宽再测真实卡高,列内用 computed gap 紧凑堆叠。
  • 动态边界:只有当容器宽度、卡数和所有卡高有效时才进入 absolute Masonry ready;否则保留 Grid fallback。ResizeObserver + requestAnimationFrame 在容器变宽、图片/字体/文本改变高度、筛选重排和 cursor 追加后全量重算;cleanup 必须兼容 StrictMode,避免分页 sentinel 因容器短暂零高提前触发。
  • 验证:纯函数测试锁定容器宽度临界值、index % columns 和最高列容器高;样式契约同时锁定 Grid fallback 与 Masonry ready。Playwright 需在同一 viewport 内改容器宽度验证 3/2/1 列,检查每列相邻卡间距等于 gap、容器高等于最高列底、DOM 顺序不变且无重叠/横向溢出。
  • 关联:src/index.css、src/index.test.ts、src/components/creation-home/CreationLandingView.tsx、src/components/creation-home/showcaseMasonryLayout.ts。

图片画布序列帧播放不要复用普通图片淡入样式

  • 现象:角色动作序列帧播放时看起来像每帧之间在渐变或闪烁。
  • 原因:序列帧播放器每帧切换可低至 40ms,默认约 125ms 一帧;如果帧 <img> 复用普通图片的 image-canvas-editor__layer-image--loading/--loaded,其中 opacity 180ms ease 会跨过下一帧切换,形成类似交叉淡入淡出的视觉。
  • 处理:ImageCanvasImageSequenceFrame 只使用序列帧专属 class,帧显隐用同步 opacity 硬切,并显式 transition: none;保留“下一帧未加载时继续显示上一帧”的 readiness gate。
  • 验证:ImageCanvasWorldView.test.tsx 应断言序列帧 <img> 不带普通图片 loading/loaded class,且 style 中 transition 为 none。
  • 关联:src/components/image-editor/ImageCanvasWorldView.tsx、src/index.css、src/components/image-editor/ImageCanvasWorldView.test.tsx。

图片编辑器生成类菜单要挂到页面级 portal

  • 现象:底部 生成规范 菜单、角色面板里的 角色规范 来源菜单点击后像没有弹出来,实际被按钮所在的局部滚动容器挡住了。
  • 原因:菜单仍然渲染在底部工具栏或参考图横向滚动行内部,父容器带 overflow,弹层无法越出边界;即便挂到 portal,如果菜单根节点的 pointerdown 继续冒泡到画布视口,也会先触发画布失焦并卸载面板,导致菜单项 click 前消失。
  • 处理:这类轻量菜单统一用页面级 fixed portal 挂到 document.body,位置根据触发按钮的 getBoundingClientRect() 计算;PlatformFloatingMenu 根节点必须阻止 pointerdown 冒泡,避免画布清空当前生成面板;底部 AI 工具栏在生成面板打开时仍保持可见,不要整栏隐藏。
  • 验证:测试断言菜单不包含在底部工具栏 / 参考图行里,并且生成面板打开时底部 AI画布工具栏 仍存在;规范参考图来源菜单应能通过 portal 点击“从画布中选择 / 上传图片”并写回规范参考图。
  • 关联:src/components/common/PlatformFloatingMenu.tsx、src/components/image-editor/ImageCanvasEditorView.tsx、src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx。

弹窗与 portal 必须显式获得平台及画板主题作用域

  • 现象与原因:portal 弹窗透明、slider 轨道/选中态消失,先查节点是否离开主题祖先;body portal 不会继承页面变量,缺 var 会使整条声明无效,内嵌也不保证有主题根。
  • 处理:平台弹窗由主题壳按 AuthUiContext 注入 light/dark;画板 portal 同步当前画板主题并提供完整品牌 token。纯黑预览明确不消费平台 remap,未适配暗色的固定白底工具明确 light,不用局部硬编码/fallback 掩盖缺作用域。
  • 验证:确认最近主题根、overlay 实际背景和控件关键 token 都有值;法律文档仍是高于登录遮罩的独立可滚动面板。

反馈页清空 file input 前必须先拷贝 FileList

  • 现象:点击上传凭证会打开文件选择框,但选择图片后页面没有展示预览,提交时也没有携带图片凭证。
  • 原因:浏览器传入的 FileList 可能跟 <input type="file"> 保持 live 绑定;如果先执行 input.value = '',再从参数里的 FileList 读取文件,列表可能已经为空。
  • 处理:在清空 file input 前先执行 const selectedFiles = files ? Array.from(files) : [],后续图片类型、大小、Data URL 读取和预览都基于这个普通数组。
  • 验证:PlatformFeedbackView.test.tsx 用 mock FileReader 断言选择图片后出现 反馈凭证预览,且提交 payload 带 evidenceItems[].dataUrl。
  • 关联:src/components/platform-entry/PlatformFeedbackView.tsx、docs/technical/PROFILE_FEEDBACK_BACKEND_INTEGRATION_2026-05-08.md。

统一创作页短表单软键盘打开不要露出黑底

  • 现象:小程序 / H5 移动端点击拼图或敲木鱼创作输入框后,输入框和键盘之间出现一大片黑色区域;H5 还会明显弹一下。跳一跳因为按钮区用 mt-auto 撑开页面,看起来没有同样问题。
  • 原因:旧移动键盘处理会用 --platform-keyboard-focus-offset 把 .platform-viewport-shell 整体上移;但 H5 浏览器和小程序 web-view 已会自行处理输入框可见性,二次整体上移会造成页面弹跳并露出 body 或原生 page 的黑色宿主底色。统一创作短表单若内容区按短内容收缩,也会放大这个黑底暴露。
  • 处理:UnifiedCreationPage 根容器必须保留 bg-[image:var(--platform-body-fill)] 和 overscroll-contain,内容区必须用 flex-1 min-h-0 占满统一页剩余高度;移动端键盘打开时只记录 data-mobile-keyboard-open、隐藏底部 dock、设置键盘 inset 和浅色 --platform-keyboard-exposed-fill,不要再对 .platform-viewport-shell 做全局 transform;小程序 pages/web-view 的 page 和 web-view class 也要用浅色背景。不要只给某个玩法工作台单独加高度补丁。
  • 验证:npm run test -- src/components/unified-creation/UnifiedCreationPage.test.tsx src/components/unified-creation/UnifiedCreationWorkspace.test.tsx src/mobileViewportKeyboardFocus.test.ts src/index.test.ts miniprogram/pages/web-view/index.style.test.js;移动端点击拼图、敲木鱼、跳一跳输入框时,页面不应整体弹起,键盘上方应持续显示平台浅色背景。
  • 关联:src/components/unified-creation/UnifiedCreationPage.tsx、src/mobileViewportKeyboardFocus.ts、src/index.css、miniprogram/pages/web-view/index.wxml、miniprogram/pages/web-view/index.wxss、docs/【玩法创作】平台入口与玩法链路-2026-05-15.md。

遮罩点击关闭必须校验完整指针序列

  • 现象:在弹窗内容内按下鼠标,拖到弹窗外的遮罩上松开时,弹窗被误关闭。
  • 原因:只在 click 阶段判断 event.target === event.currentTarget 不足以确认用户点击了遮罩;跨弹窗边界松开时,浏览器可能把合成点击的目标归到弹窗和遮罩的共同祖先。
  • 处理:共享弹窗统一记录 pointerdown 与 pointerup 的目标,只有按下和松开都发生在遮罩自身时才允许关闭。新增弹窗优先复用 UnifiedModal,不要继续复制只判断最终 click 目标的手写遮罩逻辑。
  • 验证:回归测试同时覆盖“弹窗内按下、遮罩松开不关闭”和“遮罩按下、遮罩松开正常关闭”。
  • 关联:src/components/common/UnifiedModal.tsx、src/components/common/UnifiedModal.test.tsx、src/components/auth/PlatformAuthModalShell.test.tsx。

共享组件样式必须完整随组件加载,不能依赖宿主顺序

  • 现象与根因:共享弹窗在网页正常、AGC 面板透明,是 platform-modal-shell/platform-modal-backdrop/platform-overlay 只存在于整站 src/index.css;公共后台样式迁到组件后,响应式覆盖失效,则是组件 CSS 的加载位置由模块图决定,同优先级规则不再保证页面表最后加载。两者都把共享组件外观隐式交给了宿主。
  • 归属:共享组件产出的类由所有宿主加载的共享表唯一持有;上述 modal 类位于 packages/shared/src/components/styles.css,后台公共类位于 packages/shared/src/components/admin/admin.css。基础声明及其响应式覆盖一起迁移,确保共享表独立完整;页面只保留页面专属类或显式更高优先级的覆盖。缺样式时不要再复制进某一宿主业务表。
  • 迁移检查:按「选择器 → 有效声明」比较迁移前后,同优先级按加载顺序合并、媒体块按条件分桶。测试若用 ruleFor() 取第一条匹配规则,拼接共享表应在前,避免先撞上暗色后代选择器而读错声明。
  • 验证:apps/ai-game-creator-shell/tests/projectAssetPickerDialogShellStyle.test.tsx 按真实 import 链核验 modal 类可达、唯一、引用主题 token,light token alpha ≥ 0.9;apps/admin-web/src/styles/admin.test.ts 的 admin component style ownership 校验公共类归属。这些是 CSS 声明级验证,jsdom 不计算外部 CSS;真机仍须确认弹窗实底、响应式排版和 toast 避让导航。
  • 关联:src/components/common/UnifiedModal.tsx、packages/shared/src/components/{styles.css,admin/admin.css}、apps/admin-web/src/styles/{admin.css,admin.test.ts}、docs/technical/【前端架构】共享基础组件库与展示页-2026-08-26.md。

React 异步读取必须在组件卸载时中止并失效(2026-08-04)

  • 现象:单个 Vitest 文件全部通过,全量 CI 却在 jsdom 环境销毁后出现 ReferenceError: window is not defined;栈指向请求 Promise 的 finally 中调用 React setState。
  • 原因:测试触发了与断言无关的账户读取,较快环境中请求会在用例结束前失败,较慢 CI 中请求延迟到组件和 jsdom 均已销毁后才收束。仅用 revision 丢弃旧请求而不在卸载时推进 revision,最后一个在途请求仍会被误认作当前请求。
  • 处理:调用方在未认证时不得启动受保护的钱包刷新;可取消的读取要为每轮分配 AbortController,新读取先失效并中止旧读取,组件卸载时同时推进 revision、abort 当前请求并清空句柄。所有 then / catch / finally 在更新状态前都要检查 signal 与 revision。
  • 验证:定向测试覆盖卸载后请求 signal 已中止;同时复跑触发钱包刷新回调的画布生成集成测试和完整前端测试,不能以单文件偶然快速收束代替全量验证。

React 资源详情焦点不能依赖重建对象身份(2026-08-05)

  • 现象:音频 / 视频播放器、文档链接或收起按钮正在获得焦点时,后台 manifest 更新会把焦点突然移回详情 region;若当前资源被删除,详情虽然消失,stale focused ID 和焦点可能残留到 body。
  • 原因:资源投影每次生成新对象,useLayoutEffect([focusedResource]) 把同一资源的内容更新误判为重新进入详情;删除路径没有显式恢复状态和可聚焦 fallback,项目 / 运行视图切换也可能沿用旧 trigger。
  • 处理:焦点状态机只比较稳定 resourceId:null -> id 与 idA -> idB 聚焦详情,idA -> idA 保持当前 active element。显式收起 / Escape 才恢复原卡片与滚动;后台删除清理 focused / matching selected ID 并聚焦搜索框;项目或运行视图切换清空 trigger / restore。媒体预览副作用依赖稳定 ID、路径和类别,不因同 ID 对象重建先卸载控件。
  • 验证:媒体控件获得焦点后用同 ID 新 manifest 重渲染并断言 active element 不变;删除资源后断言详情关闭、选中清理且搜索框获得焦点;既有收起、Escape、项目切换和运行切换测试继续通过。

2026-08-05 Runtime 时间戳必须验证 Date 范围并保持来源身份

  • 现象:极大但有限的持久时间值会让 toISOString() 抛 RangeError,或让界面显示 Invalid Date;实时回复又借用其它 Runtime 的最近活动时间,文字继续流入时仍显示几分钟前,缺失时还随前端定时器漂移。
  • 处理:秒/毫秒归一化后必须再检查 Date#getTime();不可表示的值统一显示“时间未知”并省略 datetime。实时回复只使用 response stream 自己的 updatedAt,不能借父/子 Runtime 活动时间或 Date.now()。

异步任务接受后的刷新回调不能统一套用 dialog 所有权(2026-08-05)

  • 现象:正式生成任务已被后端接受,用户随后删除 dialog 或切换项目,任务仍继续并可能扣费,但钱包和任务列表没有刷新;反向问题是账号切换时若 project ID 暂时相同,旧任务可能刷新新账号的任务列表。
  • 原因:把 dialog / canvas 的完整 UI 所有权同时用于账号级钱包和账号内项目级任务列表,或者任务列表只比较 project ID,没有校验账号。
  • 处理:按副作用分层校验。钱包只比较账号;任务列表比较账号加项目;dialog、canvas、asset 和 layer 写回继续比较账号、项目、scope version 与原 dialog。正式请求已接受后,删除 UI 状态不等于取消后端任务。
  • 验证:分别覆盖删除 dialog、同账号切项目、账号 A 切到账号 B 且 project ID 保持相同,以及原账号原项目原 dialog 仍有效的正常回写。

AGC 复用网页端 src/components/image-editor/ 组件会拖进网页端服务层(2026-09-10)

  • 现象:AGC 侧刚把 ImageCanvasSelectedLayerToolbarView 引进来,npm run ai-game-creator-shell:typecheck 立刻报十几条不在自己代码里的类型错误,全部指向主仓 src/services/host-bridge/hostBridge.ts(Property 'wx' / 'ReactNativeWebView' does not exist on type 'Window')。
  • 原因:跨端组件有一条值级依赖链:ImageCanvasSelectedLayerToolbarView → ImageCanvasGenerationModel(error instanceof ApiClientError 是值导入,不能退化成 type-only)→ src/services/apiClient.ts → hostBridge.ts。hostBridge.ts 读取的 wx / ReactNativeWebView / WeixinJSBridge 只在主仓 src/vite-env.d.ts 里声明过,而 AGC 的 apps/ai-game-creator-shell/tsconfig.json 是 strict: true,主仓不是。
  • 处理:在 apps/ai-game-creator-shell/src/vite-env.d.ts 的 interface Window 上补与主仓同名同形的字段(纯类型声明、零运行时影响)。不要为了让它过而关闭 AGC 的 noImplicitAny(等于为借一个组件把整个 App 的类型门禁降级),也不要因此在 AGC 里另写一套平行组件。
  • 运行时结论(已核实,可放心):hostBridge.ts 的模块作用域只有常量与缓存声明,没有导入即执行的副作用;window.wx 只在函数体内访问,ReactNativeWebView 有 typeof window !== 'undefined' 守卫。所以这条链被编进 AGC bundle 是惰性的。
  • 通用规则:今后任何往 AGC 引 src/components/image-editor/ 组件的改动,都要先检查这条链(组件 → ImageCanvasGenerationModel → apiClient → hostBridge),并确认新增的跨端全局在 AGC 侧有声明。
  • 关联:apps/ai-game-creator-shell/src/vite-env.d.ts、src/vite-env.d.ts、src/components/image-editor/ImageCanvasGenerationModel.ts、src/services/apiClient.ts、src/services/host-bridge/hostBridge.ts。

AGC 走不了网页端 /api/editor/... 的取数链(Tauri asset 协议 + CSP 双约束)(2026-09-10)

  • 现象:把网页端图片画布的服务模块(src/services/image-editor/editorProjectClient.ts)引到 AGC 后,loadEditorProject / loadOrCreateRecentEditorProject 一类调用在打包态(真机 Tauri 客户端)拉不到任何数据。
  • 原因(两条独立约束,缺一不可绕过):
    1. 相对路径:该模块全部经 src/services/apiClient.ts 的 requestJson / fetchWithApiAuth 发请求,最终落到裸 window.fetch('/api/...')(相对路径 + credentials: 'same-origin')。AGC 打包态页面来自 Tauri asset 协议(frontendDist: ../dist),相对 /api/... 打到 asset 协议自身,永远到不了 api-server。
    2. CSP:apps/ai-game-creator-shell/src-tauri/tauri.conf.json 的 connect-src 只放行 'self'、https://agc-dev.oss-rg-china-mainland.aliyuncs.com、http://127.0.0.1:*、ws://127.0.0.1:*(devCsp 同口径)。https://dev.genarrative.world 与 https://www.genarrative.world 都不在名单里——即便改成绝对地址也会被 WebView 拒掉。
  • 更正(2026-09-24):AGC 渲染层已经不再持有任何 HTTP 通道。平台接口、OSS 素材直传、Provider、错误上报与账户/认证的网络 IO 全部下沉 Rust(src-tauri/src/account_api.rs、auth_session.rs、platform_asset_upload.rs、game_distribution_publish.rs、error_report/submit.rs),clientHttp.ts::fetchClientHttp 与 Tauri HTTP 插件通道已删除,capabilities/main.json 也不再授予 http:default。
  • 判据陷阱:npm run dev 下 AGC 的 vite 配了 /api 代理(apps/ai-game-creator-shell/vite.config.ts 的 server.proxy),那些裸 fetch('/api/...') 在 dev 里看起来能跑。判断一个网页端模块能不能被 AGC 复用,必须按打包态(asset 协议 + CSP)推演,不能拿 dev 的现象当证据。
  • 关联:src/services/image-editor/editorProjectClient.ts、src/services/apiClient.ts、apps/ai-game-creator-shell/src/services/clientHttp.ts、apps/ai-game-creator-shell/src-tauri/tauri.conf.json、apps/ai-game-creator-shell/vite.config.ts。

资源画布浮层必须同时验证绘制层级与点击命中

  • 现象与机制:画布 scene 盖住搜索/提示条,或透明整屏浮层吞掉标题栏点击;DOM 存在与 handler 能调用,不证明真实可见可点。
  • 处理:按实际绘制层级定位浮层,非交互整层 pointer-events:none,只让提示条恢复点击,不能扩大整屏透明命中区。
  • 取证:AGC jsdom 不加载外部 CSS,fireEvent/getByLabelText 和 toBeVisible 不能证明真实层级。测试打开真实浮层检查交互;浏览器用 computedStyle 和 elementFromPoint 核验标题栏、按钮与浮层命中。

AGC versions[].createdAt 是 Unix 秒,渲染前必须 ×1000(2026-09-10)

  • 现象:游戏版本选择器里所有版本都显示 1970/1/22 00:41:17。
  • 原因:写入侧 apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs 的 created_at: unix_timestamp()(两处:ensure_initial_game_iteration_version_at、append_agent_game_iteration_version_at),而 unix_timestamp() 取 SystemTime::duration_since(UNIX_EPOCH).as_secs() —— 单位是秒。显示侧 formatIterationVersionLabel 却写成 new Date(version.createdAt),秒值被当成毫秒,1788075047 → 1970/1/22 00:41:15。
  • 处理:new Date(version.createdAt * 1000),与既有秒口径(ResourceAssetDeleteDialog.tsx、待确认资源编辑创建时间标签)保持一致;并给 GameIterationVersion.createdAt 补契约注释写明单位。
  • 门禁为什么抓不到:单测夹具本身就是毫秒值(createdAt: 1_760_000_000_000),断言用 new Date(夹具值) 反推期望,夹具和实现一起错,用例自洽却失真。
  • 验收口径:夹具必须与落盘口径一致(真实秒值),并用已知秒值断言渲染出的年月日(1788075047 → 2026/8/30),而不是只断言"不等于 1970"。改成 new Date(createdAt) 时该用例会失败(已实测)。
  • 关联:apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasVersionBindingModel.ts、packages/shared/src/contracts/gameCreationApp.ts、apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs。

2026-09-12 运行模块切换版本报 listeners[eventId].handlerId:tauri 2.11 的注销竞态,修在订阅入口而不是吞异常

  • 原因:Tauri 2.11 的事件注册 eval 与 listen IPC 返回没有顺序保证。StrictMode 或立即 cleanup 会在监听表项写入前执行注销,读取缺失的 handlerId 抛错,连带阻断后端注销并泄漏订阅。
  • 处理:统一经过 tauriEventSubscription.ts。真实 WebView 直接登记并保存 eventId/handlerId,注销时先幂等移除 JS 回调,再注销后端订阅;失败显式记录。浏览器或测试替身仍使用注入的 event.listen。
  • 验证:覆盖注册未落地就 cleanup、重复注销、切换版本与卸载,确认不漏订阅且正常事件仍送达。
  • 后续:升级到包含上游修复的 Tauri 版本并验证竞态后,可移除 internals 分支,恢复官方 listen 入口。
  • 关联:apps/ai-game-creator-shell/src/services/tauriEventSubscription.ts、apps/ai-game-creator-shell/tests/tauriEventSubscription.test.ts、apps/ai-game-creator-shell/tests/runVersionSwitchEventSubscription.test.tsx。

2026-09-16 Lexical 投影丢掉引用后的换行:@素材 和下一段粘成一个词

  • 现象:聊天输入区里先 @ 一个素材、回车换段再写文字,提交出去的 canonical content 里没有任何分隔,直接读成 @hero把这一版改成夜景;同一个字符串还会进 agent 输入、队列 chip 文案与润色判据。
  • 原因:3c7b02b9f(2026-09-15)为了让 content 通过 Rust 的「空 input_text」校验,在投影层加了 appendInputText(if (text.trim()) 才落 part,并与相邻文本合并)。root 子节点之间补的段落分隔符与 LineBreakNode 传进来的都是 '\n',trim() 为空 ⇒ 整段丢掉;chip 后那一段文字随后另起一个 part,派生文本用 '' 直接拼接,于是粘成 @hero把这一版改成夜景。41366dd71 又把消息正文 / 队列 chip / 快速编辑的文本派生切到这条投影上,缺陷扩散到界面与出站 prompt。
  • 处理(最终口径):不保留任何前端过滤,而是去掉规则和它的成因——Rust validate_direct_codex_user_item 改成只判整条 content(content_has_meaningful_input:有一段非空白文本或任意非文本 part 即有效),单个纯空白 input_text 合法;ResourceReferenceInput 的投影原样透传编辑器节点,既不丢空白也不与相邻 part 合并。中间版本(把待写文本「向前合并」到下一个 part)已随之删除:它仍会丢掉尾随换行与「两个 chip 之间只隔一个换行」的分隔,也仍要让前端替用户改写内容。
  • 验证:Rust validation.rs / wire.rs 新增「单个纯空白 part 通过校验、整条全空白拒绝」用例;apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx「引用后面的段落分隔原样落进 content」断言 [ref, { type: 'input_text', text: '\n' }, { type: 'input_text', text: '…' }] 与派生文本逐字一致;tests/appSurface/project-development.suite.ts 的 Godot 用例断言 Shift+Enter 的两个换行各自成 part。
  • 关联:apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx(collectDraftParts)、apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs、docs/【功能说明】AGC聊天素材引用-2026-09-08.md(原开发期里程碑已于 2026-09-24 验收归档并删除,canonical content 口径以该功能说明为准)。

2026-09-16 派生文本漏传 manifest.assets:@引用 从显示名退化成内部 id

  • 现象与原因:chip 显示名正常、出站 prompt/队列标签却退成内部 resourceId,查派生函数是否漏传 manifest.assets;assets=[]默认值把漏上下文从编译错误降成静默文案退化。
  • 处理:需要权威素材清单的参数必填,刻意无上下文才显式[];消息正文、队列 chip、快速编辑回填和草稿比较共用同一派生口径。memo/callback 依赖包含素材清单,避免补参后仍读旧值。
  • 边界:比较用草稿与实际回填文本不能各用一套规则,否则 replaceText 每次输入都会重跑。

2026-09-21 受控 Lexical 输入区的回写用被动 effect:滞后渲染的 props 会把用户草稿清空

  • 现象:DirectProject 输入盒里粘贴(或连续输入)长文本,提交时 chat_with_game_creator_direct_codex 根本没发出去,界面停在空输入盒;chat-composer 用例里表现为「队列/终止/语音追加」五条一起红,但手工操作只在快速输入后偶发。
  • 原因:ResourceReferenceInput 的受控回写写在 useEffect 里,判据是「value/references 与编辑器当前草稿不一致就重建 root」。宿主对草稿的回显(chatInput/chatReferences)永远晚于编辑器的 Lexical 提交:一次渲染提交后它的被动 effect 可能排在用户下一次输入之后才执行,于是它读到的是旧 value 加新编辑器状态,判定不一致→applyDraftToRoot 整份重建→编辑器 onChange 把空草稿写回宿主→宿主再回显空值,来回清空。references 每次 handleComposerDraft 都换数组身份,进一步保证这段 effect 每次都跑。
  • 处理:受控回写改成 useLayoutEffect,与本次提交同帧执行,读到的 props 与编辑器状态属于同一次提交;被动 effect 的滞后回调不再可能出现。当时的 value+references 双轨语义已在 2026-09-21 的 composer canonical content 闭环里收口:ResourceReferenceInput 现在只接 initialContent 与 onChange(draft: ChatComposerDraft),ChatComposerDraft 只剩 content 一个字段,旧文本 + 引用重建 root 的回写路径已删除(docs/【功能说明】AGC聊天素材引用-2026-09-08.md)。
  • 验证:apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts 的队列、终止×2、恢复回合、语音追加五条用例转绿;tests/resourceReferenceInput.test.tsx 全绿。
  • 关联:apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx、docs/【功能说明】AGC聊天素材引用-2026-09-08.md(该闭环里程碑已于 2026-09-24 验收归档并删除)。

策划聊天不能按可选子节点序号分配消息高度

  • 现象与原因:新增可选状态条后消息框随内容增长,查是否按第二个子节点分配 1fr;共享类名不代表策划和 Direct 布局合同相同。
  • 处理:以消息列表语义指定伸缩,阶段/待处理区限高滚动,窄屏仍在固定外壳内有可滚动工作台。低优先级 height:auto 不能覆盖更强祖先 height:100%。
  • 取证:夹具包含真实窗口外壳/启动器层叠,在浏览器用空/短/长回复与长待办实际滚动验证输入可操作;jsdom 交互绿不证明布局。流式正文/思考与持久消息分开时,滚动跟随仍覆盖这些状态并保留用户上滚门禁;历史缺时间不补读取时刻。

2026-09-22 卡片文字用 grid 的 auto 行排版,会被按「一行」裁掉

  • 现象:AGC 模板库卡片标题看着被切掉、简介只剩一行、标签行缺半截;TEMPLATE_CARD_TEXT_HEIGHT 与真实内容相差约 24px,但卡片底部看起来仍「刚好贴住」,很容易误判成没问题。
  • 原因:文字区原本是 grid min-h-0 content-start gap-2 overflow-hidden,行高由 auto 轨道决定。auto 轨道的 max-content 高度对可换行文本等于一行的高度:标题拿到 14px(实际需要 20)、简介 14px(两行需要 32)、标签 14px(需要 19),只有最后一个子项(按钮行)拿到完整高度。真实浏览器实测 clientHeight/scrollHeight 为 14/20、14/32、14/19。jsdom 不计算布局,单测全绿也照不出来。
  • 处理(现行口径):文字区改 flex flex-col,每行写死高度并加 shrink-0(标题 h-5、元信息 h-4、简介 h-8、标签 h-5.5、按钮行 h-7,内边距 p-3、行距 gap-2),行高契约按同一组分项常量(TEMPLATE_CARD_*_HEIGHT)算出文字区 174。卡片行高、TemplateCard 类名与这组常量必须同时改。
  • 写死高度的连带约束:动作行一旦固定成 h-7,那它就只能放两个按钮。再往里塞第三段文本(当时的「正在下载模板」)时,最小卡宽 250px 下按钮文案会被挤成两行并顶出卡片(用户看到「使用模 板 / 更 新」叠成一团)。现行口径是忙状态写在触发它的按钮上(下载中 / 创建中,按钮就地换图标+文案),按钮一律 whitespace-nowrap shrink-0,动作行 overflow-hidden 兜底。
  • 验证:真实浏览器逐行核对 clientHeight === scrollHeight(20/20、16/16、32/32、22/22、28/28);单测钉住分项常量与卡片各行类名(templateLibraryGrid.test.ts、templateLibraryView.test.tsx)。
  • 关联:apps/ai-game-creator-shell/src/features/template-library/templateLibraryGrid.ts、apps/ai-game-creator-shell/src/view/template-library/TemplateCard.tsx、docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md(卡片列表虚拟滚动)。

2026-09-22 虚拟网格按「包裹层宽度」算列宽,经典滚动条一出现就多出横向滚动条

  • 现象:AGC 模板库卡片区底部在客户端里凭空多出一条横向滚动条(外层并没有横向溢出内容);窗口放到没有竖向滚动的尺寸时又不出现。
  • 原因:react-window 的内层宽度 = 列数 × 列宽,而列宽是按包裹层宽度算的(floor(容器宽 / 列数))。经典(非 overlay)滚动条会吃掉 grid 外层的 clientWidth:Windows / WebView2 上竖向滚动条约 17px,于是内层 1184 比外层可用宽 1167 宽出正好一个滚动条,react-window 就按「横向也要滚」处理。Playwright 自带的 Chromium 用的是 overlay 滚动条(占宽 0),本地量 offsetWidth - clientWidth 是 0,完全复现不出来 —— 这类问题只能在 WebView2 客户端里看,或者按算术推。
  • 处理(现行口径):computeTemplateGridLayoutWithScrollbar(templateLibraryGrid.ts)在「内容确实会竖向溢出」时先把滚动条宽度从容器宽度里扣掉再算列宽/行高,不竖向溢出时不预留(否则右侧会留一条无意义的白边);滚动条宽度由 measureVerticalScrollbarWidth() 量一次(overlay 平台为 0,逻辑自动退化)。同类虚拟列表再出现「莫名其妙的横向滚动条」,先查这里的算术,不要靠 overflow-x: hidden 掩盖(那会把最后一列切掉)。
  • 验证:templateLibraryGrid.test.ts 断言「竖向溢出时 列数 × 列宽 ≤ 容器宽 - 滚动条」「不溢出或 overlay 时与不预留完全一致」;真机客户端截图确认横向滚动条消失、右侧只剩竖向滚动条。
  • 关联:apps/ai-game-creator-shell/src/features/template-library/templateLibraryGrid.ts、apps/ai-game-creator-shell/src/view/template-library/index.tsx。

2026-09-22 「15% 透明度的焦点环」等于没有焦点提示;选中态自己的投影还会把焦点环顶掉

  • 现象:键盘 Tab 走到筛选 chip、开关、卡片按钮上时,屏幕上完全看不出焦点在哪;自动化里更隐蔽——box-shadow 计算值非空(一串 rgba(0,0,0,0) 0 0 0 0 的 Tailwind ring 占位),只查「有没有 shadow」会全部判过。
  • 原因:两个叠加的问题。① --platform-input-focus-ring 是 rgba(204,117,76,0.15),合成到页面底色后对底色只有 1.17:1,远低于 WCAG 非文本对比要求的 3:1;② 焦点环用 box-shadow 画,而选中态自己也有 box-shadow(实心 chip 的投影),选中的 chip / 分段项聚焦时环被状态投影盖掉,等于没有提示。
  • 处理(现行口径):--platform-input-focus-ring 改成实心色(浅色 #b6623f,对页面 4.3:1;深色 #9fb0ff,对深色底 6.5:1);筛选 chip / 分段项 / 排序按钮的焦点环改用 outline: 2px solid var(--platform-input-focus-ring); outline-offset: 2px——outline 不参与 box-shadow 层叠,不会被选中态投影顶掉,也不撑开布局。
  • 验证:tests/workbenchThemeContrast.test.ts 断言焦点环对两套皮肤的页面底色 ≥ 3:1;真实浏览器里对页面上全部 175 个可聚焦控件做 blur→focus 前后比对,无一例外都能看到焦点变化(改前有 8 个控件聚焦前后完全一致)。查焦点态时必须比较「聚焦前后的计算样式差异」,不能只看属性是否非空。
  • 关联:packages/shared/src/theme.css、packages/shared/src/components/styles.css、apps/ai-game-creator-shell/tests/workbenchThemeContrast.test.ts。

2026-09-28 开发态 StrictMode 双跑 effect 击穿「一次性初始化」守卫;Vite dev 缺发行入口代理

  • StrictMode:首次挂载跳过 effect 的 ref 守卫会被开发态双跑击穿,第二次用空 prop 覆盖 sessionStorage 恢复的搜索词。按上次 prop 值比较,仅真实 prop 变化覆盖本地筛选,不按第几次 effect 判断。
  • dev 发行:生产/games//由发行网关承接,Vite 缺对应代理会返回 SPA 外壳,iframe 看似加载成功。dev 按生产路径 rewrite 并清 Cookie;用包内唯一标记确认加载真实发行包,不能只凭 onLoad。

2026-09-28 发布按钮连点两次会建两份游戏:React isSubmitting 挡不住同一次渲染窗口里的双击

  • 现象:网页发布页飞快双击「提交审核」,服务端出现两份游戏(各带一个版本)。isSubmitting 是 React 状态,双击发生在同一次渲染窗口里时还没生效,再叠上 prepareGamePackage 的 ZIP 压缩耗时,两次点击各走一遍创建工作。
  • 处理:GamePublishPage 加同步 submitInFlightRef 在途守卫(进入提交前同步置位、finally 复位),并让同一次发布(含失败后重试)复用同一组 Idempotency-Key(publishKeyRef,成功后清空),服务端按幂等重放;check:game-distribution-web-publish-recovery-e2e 断言「双击只落一份游戏与一个版本」,GamePublishPage.test.tsx 15 passed。
  • 教训:任何「点一次会建资源」的入口都需要同步守卫或稳定幂等键,只靠 React 状态禁用按钮不够。

2026-09-30 居中溢出叠加内部滚动,会把顶部内容裁到滚不到的地方(游玩页启动面板)

  • 现象:极矮视口(横屏手机,游玩页 max-height: 26rem 那一档)里启动面板内容比容器高时,标题与简介被裁在容器上方,scrollTop 已经到 0 也够不到,看起来像「标题凭空消失」。
  • 根因:.game-player-launch 同时有 align-content: center(两列布局下是 align-items: center)与 overflow-y: auto。溢出量被居中分配成上下各一半,负方向那一半落在滚动范围之外——滚动只能从 0 开始,所以顶部永远不可达。只给 overflow-y: auto 或只改居中都修不掉。
  • 处理:纵向对齐改用 safe center(先写 align-content: center / align-items: center 作旧浏览器回退,再写 safe center),溢出时退回 start;并把 overflow-y: auto 提到基础规则,让启动卡片成为唯一的内部滚动兜底,页面与 .platform-tab-panel 本身仍保持 overflow: hidden。
  • 关联:src/components/game-distribution/gameDistribution.css 的 .game-player-launch 与两处媒体查询、src/components/game-distribution/GamePlayPage.tsx。

2026-09-30 单列 grid 的 auto 轨道被 nowrap 文本撑开,移动端面板只剩横向滚动

  • 现象:「我的游戏」页在 390/360 宽的手机上可以横向滑动,卡片比可视区宽(实测卡片 592px、可视 390px、面板横向可滚 230px),看起来也不居中;同一页面的画廊卡片却正常。
  • 根因:三件事叠在一起。① .my-game-list 是单列 grid,隐式列宽是 auto,而 auto 轨道的最小尺寸是内容最小宽度,不会被容器宽度压回去;② .my-game-card(flex 行)没有 min-width: 0,其最小内容宽度 = 封面 8.5rem + gap + 正文最小内容;③ 正文里的 .my-game-card__summary 是 white-space: nowrap,一条长且不可断行的简介就把最小内容宽度顶到 592px。画廊之所以正常,是因为 .game-grid 用 repeat(n, minmax(0, 1fr)),.game-card 又有 min-width: 0; overflow: hidden。
  • 处理:让「我的游戏」与画廊同口径——.my-game-list 改 grid-template-columns: minmax(0, 1fr),.my-game-card 加 min-width: 0; overflow: hidden,标题加 min-width: 0; overflow-wrap: anywhere。改后 390/360 实测轨道 = 列表 = 卡片 = 可视宽,面板横向溢出 0。
  • 同批修掉左右留白不对称:面板左留白 12/24px(Tailwind px-3/sm:px-6),右留白却是 0,列表左右留白因此是 28/16。根因是 src/index.css 里 .platform-tab-panel 写死了 padding-right: 0.25rem——它属于不分层(unlayered)规则,优先级高于 Tailwind 放进 @layer utilities 的 padding-inline,把右内边距钉死在 0;@media (max-width: 639px) .platform-tab-panel 与 .platform-desktop-shell--workbench .platform-tab-panel 又各写了一次 padding-right: 0。三处声明全部删掉,左右统一交给使用处的 padding-inline;没有 px 类的舞台(创作主页 / 我的 / 游玩页)维持 0/0。改后实测 390 下 12/12、820 下 24/24,列表左右留白 28/28 与 44/44。
  • 验证边界:布局问题 jsdom 量不出来,需要在真实浏览器中同时核对「我的游戏」和画廊卡片的面板溢出与网格轨道宽度。
  • 关联:src/components/game-distribution/gameDistribution.css、src/components/game-distribution/MyGamesPage.tsx、.game-grid/.game-card。

2026-09-30 SPA 的「返回」写死目标页,会丢掉用户真实来源

  • 现象:游戏详情页左上角写死「返回游戏广场」并把舞台设成 games。从「我的游戏」点进详情,返回后落在广场而不是我的游戏;从广场进详情,返回还会 push 一条重复的 /games,此时浏览器原生后退反而回到刚离开的详情页。
  • 根因:setSelectionStage(stage, { path }) 一律走 pushAppHistoryPath,而返回按钮复用它,语义变成「前进到广场」;来源页信息从未被记录,返回只能靠猜。
  • 处理:activeAppPageRoutes 给应用写入的历史条目补内部深度标记(push 时 +1、replace 保持),新增 hasAppHistoryBackEntry();详情/游玩页的返回改为「有应用内历史就 window.history.back()(popstate 已由 ActiveApp 同步舞台),否则 replaceAppHistoryPath 兜底到广场/详情」。深度标记而不是只看「有没有 state」是必要的:直接打开深链、或原生壳通过 host bridge 补写首条目时,history.back() 会直接退出应用。
  • 判据:真实 Chromium(dev 栈 390x844)实测 /games → 详情 → 返回 = /games;深链直开 /games/detail?id=… → 返回 = /games;详情 → 立即玩 → 返回详情 = /games/detail?id=…。jsdom 侧 activeAppPageRoutes.test.ts 锁深度标记语义,PlatformEntryActiveFlowShell.test.tsx「游戏详情返回」两条锁原生返回与深链兜底。
  • 关联:src/routing/activeAppPageRoutes.ts、src/ActiveApp.tsx、src/components/platform-entry/PlatformEntryActiveFlowShell.tsx、src/components/game-distribution/GameDetailPage.tsx。

2026-09-24 移动端固定底部菜单会压住「内联」弹窗:z-index 高也点不到

  • 现象:手机端主站从头像入口上传头像,裁剪弹窗的「取消 / 上传」被底部「游戏 / 我的」菜单盖住,点按钮命中底部菜单;弹窗遮罩、面板本身都正常渲染,只有底部一条区域失去交互。
  • 原因:.platform-desktop-shell--workbench .platform-desktop-layout 带 position: relative; z-index: 1,自成一个 stacking context。底部主菜单(.platform-mobile-bottom-dock,z-index: 60)是 layout 的兄弟节点,而弹窗用 portal={false} 内联渲染在 layout 内部——弹窗自己的 z-[80] 只在 layout 的局部层叠里比较,整体被限制在 z-index: 1 之上、z-index: 60 之下。在祖先 stacking context 内抬高子元素 z-index 无效,必须让弹窗跳出该 context。
  • 处理(现行口径):独立弹窗一律走 UnifiedModal 默认的页面级 portal(挂 document.body),不要用 portal={false} 内联渲染。目前只剩 PlatformStatusDialog 保留内联(运行态内嵌遮罩,且它渲染位置本就在 layout 之外)。
  • 同时:主站顶栏(platform-desktop-topbar)在窄屏必须保持单行——加 flex-wrap 后新增一个动作入口就会让品牌折到第二行;窄屏降级靠 < 640px 图标化下载入口、< 480px 只留品牌 IP 标识。
  • 验证:真机尺寸下用 document.elementFromPoint(按钮中心) 断言命中的是按钮自身而不是底部菜单(overlay.parentElement === document.body);顶栏断点矩阵(320–768)断言单行且无横向溢出。
  • 关联:src/components/common/SquareImageCropModal.tsx、src/components/platform-entry/PlatformProfileModalShell.tsx、src/components/platform-entry/PlatformEntryActiveFlowShell.tsx、src/index.css。

业务功能与第三方集成

发布媒体直传的幂等摘要与作者预览授权

  • 误重放:multipart 元数据不包含图片正文;摘要只算文本时,同一幂等键换图会被当成原请求,静默复用旧结果。服务端与 AGC 的摘要都要覆盖规范化元数据、新图字节和沿用 objectKey;仅换图也必须改变摘要。同一次发布重试、丢失响应或重启续传复用原账本键,不用新键掩盖未知结果。
  • 作者看不到封面:发布媒体不建素材库记录,素材库 read-url 无权读取;公开媒体路由又只放行已发布且 active 的作品。作者预览未发布、待审或被驳回作品应走带 bearer 的 my-games/{gameId}/media/read-url(字节读取同理),核对作品归属与当前媒体 objectKey,不能放宽公开路由。
  • 核验:测试同键换图被识别为不同请求、同请求重试复用结果,以及作者可预览自己的未发布媒体而其他账号不可读。包入口、账本字段和媒体合同见玩法链路。
  • 现象:付费游戏在真实浏览器里打不开——播放会话 src(/api/game-distribution/play-sessions/<token>/)本身和包内每个相对资源(JS/CSS/图片/音频)全是 403,同一份包的免费游戏 /games/<gameId>/ 正常。
  • 原因:播放会话前缀落在 /api/* 上,而通用 /api location 必须转发 Cookie(/api/auth/* 依赖 refresh cookie);api-server 播放网关对带可解析平台 refresh Cookie 的请求返回 403(与发行网关同族的纵深防御,本次不放宽),于是沙箱 iframe 的每个同前缀请求都带 Cookie、都被拒。dev 侧同样复现:vite.config.ts 原本只对 /games/... 清 Cookie,/api/game-distribution 规则会转发 Cookie。
  • 处理(现行口径):三份 nginx 模板(deploy/nginx/genarrative.conf、deploy/nginx/genarrative-dev-http.conf、deploy/container/nginx.conf)在通用 /api location 之前加 location ^~ /api/game-distribution/play-sessions/——^~ 不能省,否则正则 location ~ ^/api(?:/|$) 优先命中、Cookie 又被转发;代理头 / client_max_body_size 210m / limit_conn / limit_req / 超时 / 维护判断都与通用 /api 一致,只多一条 proxy_set_header Cookie ""。vite.config.ts 在 /api/game-distribution 之前加同名前缀规则(proxyReq.removeHeader('cookie'))。Pingora 侧对应 RouteDecision::PlaySessionGateway:路径原样走 api 上游,同样套 api 限流分组、大小上限与维护闸,差别只在新增的 route_clears_cookie 清空 Cookie(发行入口 ReleaseGateway 复用同一判定)。
  • 边界:前缀只匹配带尾斜杠的形式——创建会话的 POST /api/game-distribution/play-sessions(以及 POST /api/game-distribution/games/{gameId}/play-session)需要账号凭证,必须继续走通用 /api 并保留 Cookie。网关的 403 拒绝保持不变,只在边缘/dev 保证请求不带 Cookie。
  • 门禁:npm run check:nginx-spa-routes 对三份模板断言该 ^~ location 存在、块内清空 Cookie、代理头齐全且排在通用 /api location 之前(变异验证:删掉块内 proxy_set_header Cookie ""; 立刻报「播放会话前缀 location 缺少代理片段」);npm run check:pingora-route-parity 断言矩阵 play_sessions_gateway 用例声明清 Cookie 片段、不复用通用 /api location,且 Rust classify_path 的播放会话分支排在通用 /api 之前(变异验证:把矩阵片段换成通用 location、或在 Rust 里交换两个分支,各自单独判红);npm run check:pingora-gateway-smoke 用真实网关二进制断言该前缀清 Cookie、创建会话端点保留 Cookie。三条都串在 npm run lint 链里,有自动调用方。
  • 关联:server-rs/crates/pingora-gateway/src/main.rs、deploy/pingora/nginx-route-parity.matrix.json、deploy/nginx/README.md、vite.config.ts;另见本文件「SPA allowlist 不能吞掉游戏发行网关路径」条(发行入口不转发 Cookie 的同族规则)。

2026-10-05 运行画面点选 three 档的句柄少了构造器会整体失效

  • 现象:three 项目句柄里 Raycaster / Vector2 / Vector3 / Box3 只缺一个,桥就不进引擎档:芯片回落 @canvas、控制台不报错——症状与「根本没发句柄」完全一样,容易误判成「点选没生效」。
  • 原因:引擎档入口先认句柄形态、再逐个校验这四个构造器是否为 function,缺任一即按未命中退化(不降级、不猜);射线需要 Raycaster + Vector2,对象矩形需要 Box3 + Vector3,两组能力缺一方都点不出对象。
  • 排查顺序(现行口径):① 预览页有没有 window.__GENARRATIVE_PREVIEW_GAME__;② engine 是不是 phaser / three;③ 这四个构造器齐不齐(瘦身形态是 three.Raycaster 等,旧形态是 THREE.Raycaster);④ scene / camera / renderer,three 侧还要 renderer.domElement 带 getBoundingClientRect。
  • 关联:apps/ai-game-creator-shell/src-tauri/resources/preview/local-preview-fit.js(threeInspectTarget)、agc-web-game-development 的「运行画面点选契约」、tests/runtimeInspectEngines.test.ts(瘦身句柄命中、缺构造器退化两例)。

2026-10-04 Phaser 4 hitTestPointer 的返回顺序不是叠放顺序

  • 现象:运行画面点选 Phaser 4 画面时,按「数组第一个 / 最后一个」当最上层会点错对象——点的是上层精灵,引用却落到下层的素材上;对象越多越容易错。
  • 原因:hitTestPointer 返回的是输入对象的注册 / 内部列表顺序,与真实叠放无关;叠放由命中相机 renderList 的索引决定(含 depth 与入序)。
  • 结论(现行口径):点选命中要按 pointer.camera.renderList 的索引取最上层;几何兜底也必须按 depth + 入序排序,不能沿用注册顺序取值。

2026-10-04 Phaser 4 默认 XHR 装载下贴图元素是 blob 地址

  • 现象:运行画面点选拿不到原始素材路径——贴图元素上的地址是会话内的 blob 地址,按文件名反推素材会得到无意义的临时名。
  • 原因:Phaser 4 默认用 XHR 装载图片,地址只在当前会话内有效,不携带仓库 / manifest 里的路径信息。
  • 结论(现行口径):Phaser 4 项目的素材身份必须靠玩法显式标注(setData)发布,不要试图从 blob 地址反推文件名;没有标注就退化为不带素材的区域引用。与主规范「运行画面素材点选(2026-10-04)」的边界一致。

Godot C++ 对象不能等 DLL 静态析构才释放

Godot 绑定可能先于 DLL 静态对象退出;依赖静态析构释放 Variant、String 或 Ref 会在绑定失效后访问引擎。使用官方 godot-cpp 类型,在扩展终止回调中先停用仍存活的桥,再释放绑定对象;延迟回调前按实例 ID 检查节点存活。晚加载扩展还须解除实例包装回调,避免 DLL 卸载后跳到失效地址,同时不能销毁仍在调用栈中的引擎对象。

核验首次加载、卸载、节点退出及同 PID 重连。构建工具链与具体生命周期顺序见Godot 编辑器插件接入。

Phaser 与 CSS 双重居中导致游戏画面偏移

Phaser Scale.FIT 与 autoCenter: CENTER_BOTH 会给 canvas 计算定位外边距。若 canvas 的直接父容器同时使用 display: grid; place-items: center 或另一套 CSS 居中,浏览器再次定位带 margin 的元素,竖屏游戏会相对预览区域偏右。应只保留一个居中责任方:Phaser 居中时直接父容器使用尺寸明确的普通块布局;CSS 居中时设置 autoCenter: NO_CENTER。不禁止外围页面的 Grid/Flex 布局,不通过修改 AGC iframe 的固定偏移掩盖项目 CSS 问题。

开发 Agent 的实际系统工程提示和 agc-web-game-development Skill 均包含此规则。布局修改后重新构建 dist,分别在桌面、移动与 resize 后测量 canvas 相对游戏父容器的中心误差(预期居中时不超过 1 CSS px),同时检查无溢出和意外滚动条;构建成功不等于视觉验收通过。

Cocos 的导入回执不等于资源与预览已经就绪

  • AssetDB reimport 返回时 SpriteFrame 可能仍不可加载;先有界预加载再开始编辑事务。未命名场景直接 save-scene 会弹出交互窗口,应通过 AssetDB 创建、等待导入、标记已保存,再用官方 open-scene 打开。
  • Scene WebView 的旧全局 cc 不含所有构造器(如 UITransform);执行代码使用 require('cc') 完整模块。
  • 独立预览窗口先加载 about:blank 建立 renderer,再启用 CDP;初始化和导航共用一个截止时间,失败只关闭本次创建的窗口。
  • 具体引导、事务和回读合同见Cocos 编辑器桥接。

2026-09-12 Cocos 项目识别不等于编辑器桥就绪

  • 现象:能发现正确 Creator PID、Agent 也有 agc_cocos_execute,但首次执行报 pipe 不存在;仅登记目标的 connect 会误报成功。
  • 处理:Windows execute/connect 先统一复用 pipe 或通过目标 PID 的 Inspector 引导,握手通过后才发布连接或发送代码;开发与发布脚本均默认带 cocos-editor-execute。Node 规范化前要处理 Rust 扩展路径;成功安装后只关闭本次开启的 Inspector。
  • 验证:必须分别跑自有进程冷启动/已有 Inspector/超时回归与真实 Creator 首次连接;只跑 fixture 或开启 feature 不能证明真实链路可用。

编辑器生成按钮显示泥点后仍要查真实钱包预扣

  • 现象:画板生成按钮显示 N泥点,后端也能按模型配置计算出价格,但用户点击后钱包余额不变。
  • 原因:前端展示价和后端价格计算只证明价格能被展示 / 解析;如果 handler 没有包进 execute_billable_asset_operation_with_cost,或异步音频发布目标没有携带本次模型价格,外部 provider 仍会被调用但不会真实扣费。
  • 处理:新增或改造编辑器外部生成入口时,确认前端请求不携带 priceMudPoints,后端按运行时模型定价重新计算价格,并用该价格进入资产扣费 wrapper。音频提交 / 发布分离时,把后端计算出的价格写入 AudioAssetBindingTarget.billing_points_cost。
  • 验证:结构性测试覆盖对应 handler 包含 execute_billable_asset_operation_with_cost 和价格变量;worker_billing_context_freezes_charge_and_preserves_job_metadata 与 logged_in_background_music_queue_preparation_uses_canonical_prompt_and_frozen_price 覆盖现役队列冻结价格,原子计费测试覆盖提交失败与结果未知时的退款边界。
  • 关联:server-rs/crates/api-server/src/editor_project.rs、server-rs/crates/api-server/src/character_animation_assets.rs、server-rs/crates/api-server/src/vector_engine_audio_generation/、src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts。

Vidu 文生音频线上网关可能要求 sound 字段

  • 现象:画板点击 生成游戏音效 后,请求返回 Failed to deserialize the JSON body into the target type: missing field sound。
  • 原因:VectorEngine Apifox 创建文生音频任务 文档仍写 /ent/v2/text2audio 使用 model + prompt + duration,但线上 Vidu 网关曾按 sound 字段反序列化;只发送 prompt 会被上游拦截在 JSON 解析阶段。
  • 处理:前端和 BFF 对内继续使用用户语义更清晰的 prompt;platform-audio 转发到 VectorEngine Vidu 时同时发送 prompt 与 sound,两者值保持一致。不要把 UI 改回 Suno task: "sound"、type、tempo 或 BPM。
  • 验证:cargo test -p platform-audio --manifest-path server-rs/Cargo.toml --test vector_engine_audio 中音效请求体测试必须同时断言 prompt 与 sound;必要时用线上生成音效 smoke 确认不再出现 missing field sound。
  • 关联:server-rs/crates/platform-audio/src/request.rs、server-rs/crates/platform-audio/tests/vector_engine_audio.rs、docs/【编辑器】画板音乐生成入口设计-2026-06-18.md。

Suno 任务完成或返回 audiopipe 不代表已经拿到稳定下载地址

  • 现象:画板生成背景音乐时,前端可能报 音频生成尚未返回可下载地址(requestId:...)、获取 Suno 音效 wav 失败(requestId:...) 或 读取生成音频内容失败:error decoding response body。上游任务可能已经完成并返回 https://audiopipe.suno.ai/?item_id=...,但该地址仍可能以 200 + chunked 开始响应后不返回完整正文。
  • 原因:VectorEngine Suno /suno/fetch/{task_id} 可能先在 data 中返回歌曲 / 音效 clip id,或返回只携带 item_id 的 audiopipe 流式中转地址,而不是稳定 .wav / .mp3 文件 URL;需要再调用 /suno/act/wav/{clipId} 获取实际文件地址。如果只在“完全没有 URL”时回退 wav,会误把 audiopipe 当最终文件并让 worker 在正文读取阶段卡满请求超时。
  • 处理:platform-audio 查询 Suno 结果时保留普通直接音频 URL;遇到 audiopipe 时不直接下载,而是从查询结果的 id / clip_id / audioId / songId 或 audiopipe item_id 提取 clip id,逐个调用 /suno/act/wav/{clipId}。已拿到 clip id 但 wav 地址仍未就绪,或 wav 子请求暂时返回上游错误时,都保持 processing 让上层继续轮询,不能直接判定为缺少可下载地址或 wav 获取失败。
  • 验证:cargo test -p platform-audio --manifest-path server-rs/Cargo.toml;cargo test -p api-server vector_engine_audio_generation --manifest-path server-rs/Cargo.toml。
  • 关联:server-rs/crates/platform-audio/src/client.rs、server-rs/crates/platform-audio/src/response.rs、docs/【编辑器】画板音乐生成入口设计-2026-06-18.md。

图片画布快速编辑尺寸要区分用户目标和 provider 对齐尺寸

  • 现象:原图经过快速编辑后 Resolution 变成近似比例的 1K / 2K 预设;原图或框选标记图宽高不是 16 的倍数时,VectorEngine edits 直接拒绝请求。
  • 原因:前端已有源图精确 originalWidth/originalHeight,提交时却按最近常用比例和 K 档重新计算 size;后端又把非 16 倍数的目标尺寸和原始参考图字节直接放进 multipart,并以 provider 回图宽高落库和覆盖画布图层。
  • 处理:画布快速编辑展示与常规图片生成一致的模型、比例和尺寸参数,默认继承来源生成器参数;缺少来源生成器时使用图层模型,并按真实分辨率推导比例和尺寸。用户当前选定的比例和尺寸共同决定业务目标分辨率,允许覆盖源图旧分辨率;api-server 只在 provider 边界向右、向下复制边缘像素,把每张参考图和目标尺寸临时补齐到 16 的倍数,收到回图后裁回业务目标尺寸再持久化。若上游异常返回其他尺寸,先按目标比例裁切缩放;临时对齐尺寸不能进入 OSS 元数据、editor_project_resource、editor_asset 或画布 Resolution。结果覆盖目标图层时更新原始分辨率,并保持图层中心位置不跳动。
  • 验证:npm run test -- src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx 覆盖来源参数继承、模型参数切换、目标尺寸提交和图层回填;cargo test -p api-server editor_image_edit --manifest-path server-rs/Cargo.toml 覆盖图标类拒绝、provider 尺寸对齐和回图恢复。
  • 关联:src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts、src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts、src/components/image-editor/ImageCanvasGenerationLayerModel.ts、server-rs/crates/api-server/src/editor_project.rs。

图片画布快速编辑模型必须在后端选择正确的 provider 协议

  • 现象:快速编辑继承或选择 nanobanana2 后,上游返回 not supported model for image generation;图集开放快速编辑后尤其容易触发。
  • 原因:前端把 gemini-3.1-flash-image-preview 正常提交到 /api/editor/images/edits,但后端无条件使用只支持 gpt-image-2 的 VectorEngine /v1/images/edits multipart 协议。
  • 处理:快速编辑请求同时提交 model / aspectRatio / imageSize。api-server 归一模型后分流:nanobanana2 使用 /v1beta/models/{model}:generateContent,把原图和参考图放入 inline_data,并传递 generationConfig.imageConfig;gpt-image-2 继续使用 /v1/images/edits multipart 和 16 像素 provider 边界对齐。nanobanana2 保留 provider 输出几何尺寸,不套用 GPT edits 的像素恢复。
  • 验证:npm run test -- src/services/image-editor/editorProjectClient.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx 覆盖前端参数提交;cargo test -p api-server editor_image_edit --manifest-path server-rs/Cargo.toml 覆盖模型分流、尺寸档位计费与 GPT 对齐恢复。
  • 关联:src/services/image-editor/editorProjectClient.ts、src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts、server-rs/crates/api-server/src/editor_project.rs、server-rs/crates/api-server/src/openai_image_generation.rs。

小程序 H5 导航不能清掉宿主 query

  • 现象:微信小程序首次进入 H5 后,点击需要登录的入口没有返回小程序原生授权页,而是弹出 Web 端登录窗口;充值渠道也可能被误判为普通网页环境。
  • 原因:小程序 web-view 入口通过 clientType=mini_program、clientRuntime=wechat_mini_program、miniProgramEnv 标记宿主环境,但 H5 内部 pushAppHistoryPath(...) 阶段导航会默认清空 query;首点时微信 JS bridge 也可能尚未就绪,导致 isWechatMiniProgramWebViewRuntime() 和充值平台判断读不到小程序上下文。
  • 处理:路由层统一把 clientType、clientRuntime、miniProgramEnv 当作 app runtime context,在普通路径归一、显式 query 路由和同一创作流跳转时都跨导航保留;小程序环境识别同时用 MicroMessenger + miniProgram User-Agent 兜底首点 bridge 未就绪场景;创作恢复参数仍只在同玩法创作流内保留,离开创作流时继续清理。
  • 验证:npm exec vitest run src/routing/appPageRoutes.test.ts src/components/auth/AuthGate.test.tsx src/services/authService.test.ts src/services/payment/paymentPlatform.test.ts。
  • 关联:src/routing/appPageRoutes.ts、src/services/authService.ts、src/services/payment/paymentPlatform.ts、docs/【项目基线】当前产品与工程约束-2026-05-15.md。

VectorEngine 图片生成 request_send 传输错误要按可重试网络抖动排查

  • 判别:failureStage=request_send、statusCode=null 表示未获得可归类的 HTTP 响应;upstream_status 表示已收到上游响应。超时、连接重置、SSL 握手或收包提前 EOF 优先查出口、代理、请求体大小和 provider 状态;明确审核错误不按网络抖动处理。
  • 处理:按 request_id 关联调用者与失败请求,区分单次 attempt 警告和最终 external_api_call_failure。现有策略对指定传输错误及 408/429/5xx 重试,edits 每次重建 multipart;不能把一次失败日志直接当成整个请求失败。
  • 传输边界:图片 generations/edits 上游 POST 使用 libcurl,参考图和响应图片下载仍用 reqwest。线上出口与本地可能不同,用同一图片和请求对照传输链路,不能靠某次固定 IP 或一次 curl 成功排除网络问题。
  • 拼图排查:同一 request_id 下按资产生成顺序查最后失败的 slot,避免把前一阶段成功误判成整个草稿成功。
  • 关联:server-rs/crates/platform-image/src/vector_engine/client.rs、server-rs/crates/api-server/src/external_api_audit.rs、server-rs/crates/api-server/src/openai_image_generation.rs、docs/【开发运维】本地开发验证与生产运维-2026-05-15.md。

OSS V4 签名日期必须定宽,object_key 不含 bucket

  • 现象与原因:上传或私有读签名报 OSS V4 签名时间格式化失败、上游拒绝签名,业务可能已完成生成却在转存或读签名阶段失败。依赖 time::Time::to_string() 再去冒号不能保证个位时间补零。
  • 处理:签名 scope 固定为 YYYYMMDD,x-oss-date 固定为 YYYYMMDDTHHMMSSZ,显式补零格式化。调用读签名或 HEAD Object 只传 object_key,不传 bucket/object_key 拼接路径。
  • 验证与入口:cargo test -p platform-oss --manifest-path server-rs/Cargo.toml;覆盖个位小时、分钟和秒,以及 bucket 与 object_key 分开传入。实现见 server-rs/crates/platform-oss/src/lib.rs。

抓大鹅生成页只显示服务暂不可用先查 reason 和外部服务配置

  • 现象:点击生成抓大鹅草稿后,页面只提示“服务暂不可用”,或者本地 npm run dev:api-server 看似启动但生成接口不可用。
  • 原因:配置缺失类错误通常在后端 error.details.reason 中给出具体缺项,前端如果只读 details.message 会吞掉原因;本地只配置 ALIYUN_OSS_BUCKET / ALIYUN_OSS_ENDPOINT 时,旧逻辑还会在启动期构造空 AccessKey 的 OSS 客户端并失败。抓大鹅新链路仍是 2D 生图切割,不需要也不应回退 Rodin/GLB。
  • 处理:前端 API 错误展示优先读取 details.reason,再读取 details.message,避免底层 error sending request 覆盖真正可操作的配置或网络原因;api-server 只有在 OSS 四件套齐全时初始化 OSS 客户端,部分缺失只记 warning 并让具体 generated 上传/换签接口返回 OSS 未完成环境变量配置。抓大鹅素材、封面和背景生成在调用 VectorEngine 前先预检 OSS,并通过 details.missingEnv 列出缺项;真实生成需补齐 VECTOR_ENGINE_BASE_URL、VECTOR_ENGINE_API_KEY 和完整 ALIYUN_OSS_* 四件套。抓大鹅 UI spritesheet 和物品 spritesheet 的提示词必须要求单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕背景,后端上传 OSS 前统一扣成透明 PNG,避免运行态 alpha 连通域解析失败。
  • 验证:npm run test -- src/services/apiClient.test.ts 覆盖 details.reason;cargo test -p api-server state --manifest-path server-rs/Cargo.toml 覆盖半配置 OSS 不阻断启动;npm run dev:api-server 后按实际 GENARRATIVE_API_PORT 请求 /healthz,不要默认打 3100。
  • 关联:packages/shared/src/http.ts、server-rs/crates/api-server/src/state.rs、docs/technical/API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md、docs/technical/AUTH_SNAPSHOT_AND_MATCH3D_LOCAL_DEV_FIX_2026-05-01.md。

寓教于乐作品和宝贝识物模板同时消失先查入口种子

  • 现象:发现页“寓教于乐”分类下已发布的宝贝识物作品突然消失,同时创作界面模板选项中也看不到或无法正常展示 宝贝识物。
  • 原因:创作入口配置事实源已迁到 SpacetimeDB creation_entry_type_config;前端用 baby-object-match 入口可见性同时控制创作模板展示和发现页宝贝识物公开作品合入。若默认种子或后台配置缺少 baby-object-match 行,两条链路会一起被判定为不可见。
  • 处理:确认 server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs 默认种子包含 id=baby-object-match、title=宝贝识物、visible=true、open=true、sort_order=90;api-server 测试降级配置也要同步包含该类型。入口图片路径需指向真实存在资源,避免卡片图片 404。
  • 验证:运行 cargo test -p module-runtime default_creation_entry_types_include_baby_object_match --manifest-path server-rs/Cargo.toml、cargo test -p api-server test_creation_entry_config_response_keeps_baby_object_match_visible --manifest-path server-rs/Cargo.toml、cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml 和 npm run test -- src/components/platform-entry/platformEntryCreationTypes.test.ts。
  • 关联:server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs、server-rs/crates/api-server/src/creation_entry_config.rs、docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md。

Hyper3D subscriptionKey 不要按固定短文本限长

  • 现象:抓大鹅生成草稿时,内联 Rodin 图生 3D 模型提交成功后,状态轮询报 subscriptionKey 超过 256 字符,导致 /api/creation/match3d/sessions/{sessionId}/actions 返回 400。
  • 原因:subscriptionKey 是 Hyper3D 返回的 opaque token,长度由上游决定;后端状态查询曾复用普通文本校验,把它限制在 256 字符。
  • 处理:query_task_status 对 subscriptionKey 只做 trim 和非空校验,不做固定长度限制;前端临时任务和 Match3D 草稿响应可继续展示该 token,但不要把它当作可编辑短文本。
  • 验证:cargo test -p api-server accepts_opaque_subscription_key_without_length_cap --manifest-path server-rs/Cargo.toml。
  • 关联:server-rs/crates/api-server/src/hyper3d_generation.rs、docs/technical/HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md。

微信支付回调验签不要用商户私钥

  • 现象:微信小程序支付下单能返回 prepay_id,但真实支付通知验签失败,或者本地实现误把商户 API 私钥当作回调验签 key。
  • 原因:商户私钥只用于商户请求微信支付和生成小程序 paySign;微信支付通知的 Wechatpay-Signature 需要使用微信支付平台公钥或平台证书公钥验签,并按通知头里的平台序列号匹配。
  • 处理:api-server 真实微信支付配置同时需要商户私钥与微信平台公钥:WECHAT_PAY_PRIVATE_KEY_* 用于签名,WECHAT_PAY_PLATFORM_PUBLIC_KEY_* 与 WECHAT_PAY_PLATFORM_SERIAL_NO 用于通知验签,WECHAT_PAY_API_V3_KEY 只用于解密通知 resource。微信平台 PUBLIC KEY PEM 的 DER 内容是 SPKI SubjectPublicKeyInfo,初始化时必须解析并提取其中的 PKCS#1 RSAPublicKey DER 后再交给 ring::RSA_PKCS1_2048_8192_SHA256;不能把整段 SPKI DER 直接传给 ring。支付成功后只通过通知里的 out_trade_no 确认本地 pending 订单,并保存 transaction_id 到 profile_recharge_order.provider_transaction_id。
  • APIv3 通知成功应答使用 HTTP 204 No Content,不要沿用 V2 XML 成功报文;失败仍返回 4XX/5XX 让微信重试。
  • 验证:mock 通知测试只能覆盖本地回调推进;platform-wechat 必须用标准 SPKI PUBLIC KEY 和匹配私钥生成真实 RSA-SHA256 签名,覆盖 SPKI 到 PKCS#1 的解析与生产验签 helper。真实环境还需用微信支付平台公钥、真实通知头和 API v3 密钥验证签名与解密链路。
  • 关联:server-rs/crates/platform-wechat/src/pay.rs、server-rs/crates/api-server/src/wechat/pay.rs、docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md。

微信支付 JSAPI 下单必须显式带 User-Agent

  • 现象:调用 /v3/pay/transactions/jsapi 失败,微信返回“Http头缺少Accept或User-Agent”。
  • 原因:reqwest 请求即使已设置 Accept: application/json,也不会默认附带业务侧 User-Agent;微信支付网关会校验这两个头。
  • 处理:api-server 的 JSAPI 下单请求统一通过 with_wechat_pay_jsapi_headers(...) 设置 Accept: application/json、Content-Type: application/json 和 User-Agent: Genarrative-WechatPay/1.0。
  • 验证:执行 cargo test -p api-server jsapi_order_request_sets_wechat_required_http_headers --manifest-path server-rs/Cargo.toml。
  • 关联:server-rs/crates/api-server/src/wechat_pay.rs、docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md。

充值订单过期补偿不要放进外部生成 worker

  • 现象:外部生成 worker/controller 扩容后,微信充值过期查单和关单流量也被同步放大;排查时还会误去外部生成 worker 日志里找支付过期任务。
  • 原因:支付过期是账户资金链路,不是外部内容生成队列;旧实现把充值过期轮询 worker 挂在通用后台任务启动函数里,非 HTTP 角色也会启动。
  • 处理:充值订单过期由 SpacetimeDB 原生 profile_recharge_order_expiration_timer 到点把 pending 改为 expired,只有 HTTP api-server 订阅活跃 timer 表的删除事件,按 order_id 重新读取订单并仅对 expired 查微信补偿;支付或主动关闭导致的删除信号会被状态判断忽略,断线窗口由未检查过期订单 catch-up 补齐。未支付终态本地保持 expired,不要再改写成 closed;微信成功支付通知或补偿查单仍可把 Expired -> Paid 入账。
  • 验证:确认 GENARRATIVE_PROCESS_ROLE=external-generation-worker / external-generation-controller 不启动充值过期监听;创建 pending 充值单后只由 scheduled reducer 产生 expired,HTTP api-server listener 记录 expiration_checked_at 或补入账。
  • 关联:server-rs/crates/api-server/src/profile_recharge_expiration_listener.rs、server-rs/crates/spacetime-module/src/runtime/profile.rs、docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md。

商户平台退款登记不要混淆 refund_id 与 out_refund_no

  • 现象:在“登记商户平台退款”里填写 50000000000000000000000000000 一类微信退款单号后提示找不到退款。
  • 原因:该编号是微信侧 refund_id;V3 单笔退款查询路径只接受商户退款单号 out_refund_no。把 refund_id 放进路径不会自动转换,退款查单会返回 RESOURCE_NOT_EXISTS。不过微信没有承诺 refund_id 固定为 50 开头的 29 位数字,合法 out_refund_no 也可能是纯数字,因此形状判断不能代替真实查单。
  • 处理:登记表单明确标注 out_refund_no,所有满足官方字符和长度约束的输入都交给服务端真实查询;只有查询确认不存在后,才把 50 开头的 29 位纯数字作为“疑似 refund*id”给出定向提示。只有 refund_id 时等待退款回调或 T+1 退款账单建立映射;不要调用异常退款申请接口冒充查询。out_refund_no 字符校验须覆盖官方允许的数字、大小写字母和 * - | \* @。
  • 验证:后台页面测试断言疑似编号仍交给服务端;平台适配器测试锁定 RESOURCE_NOT_EXISTS 映射和 @ 字符,并确认真正的 out_refund_no 仍调用 GET /v3/refund/domestic/refunds/{out_refund_no}。
  • 关联:apps/admin-web/src/pages/AdminRechargeOrderPage.tsx、server-rs/crates/api-server/src/admin_recharge.rs、server-rs/crates/platform-wechat/src/pay.rs。

微信支付查单的 REFUND 不等于已经全额退款

  • 现象:一笔 6 元充值在商户平台成功退 3 元并登记 out_refund_no 后,本地显示累计已退 3 元、剩余可退 3 元,但后台再次预检仍显示“未核验 / REFUND”并禁止退款。
  • 原因:微信支付订单查单的 trade_state=REFUND 只说明该支付订单发生过退款,不携带累计退款明细,也不表示已经全额退款。若后台把 verified 硬编码为 trade_state == SUCCESS,任何已成功部分退款的订单都会永久失去继续退款能力;反过来,仅看到 REFUND 就直接放行又可能漏掉未登记的商户平台退款。
  • 处理:预检先查支付订单并校验商户订单号、支付单号和总金额,再主动刷新全部已知 out_refund_no 并重读本地退款 settlement。SUCCESS 可继续预检;REFUND 仅在本地累计成功退款大于 0 且小于订单总额,并且没有 PROCESSING / ABNORMAL 退款、活动 hold、退款欠账或人工冻结时,允许继续退本地剩余额度。没有本地成功退款能解释 REFUND 时使用独立原因码阻止并要求登记或对账,不能冒充“订单未支付”。
  • 验证:后端策略测试覆盖 SUCCESS + 0/600、REFUND + 0/600、REFUND + 300/600、REFUND + 600/600;后台页面测试覆盖 已核验 / REFUND 时剩余额度可提交。真实联调核对累计退款、已追回泥点、活动占用和欠账均与退款明细一致。
  • 关联:server-rs/crates/api-server/src/admin_recharge.rs、server-rs/crates/spacetime-module/src/runtime/profile.rs、apps/admin-web/src/pages/AdminRechargeOrderPage.tsx。

跳一跳创作入口旧文案先查 SpacetimeDB 配置

  • 现象:JumpHopWorkspace 已只剩主题输入,但创作 Tab 的跳一跳模板卡仍显示旧的“俯视角跳跃闯关”或拼图参考图。
  • 原因:创作入口卡片事实源是 SpacetimeDB creation_entry_type_config 和 /api/creation-entry/config,前端只做展示派生;如果只改工作台、PRD 或前端组件,已有库里的旧入口行不会自动变化。当前 api-server 读取入口配置时优先订阅缓存,缓存命中后不会再走 procedure 播种,所以只把迁移写在 get_creation_entry_config 里不够。
  • 处理:同步更新 module-runtime 默认入口种子,并在 spacetime-module/src/runtime/creation_entry_config.rs 加只命中旧系统默认值的迁移;同时在 spacetime-client 的入口配置读模型里做同一条旧系统默认行的读路径纠偏。跳一跳当前默认值为 subtitle=主题驱动平台跳跃、image_src=/creation-type-references/jump-hop.webp。
  • 验证:本地 GET /api/creation-entry/config 的 jump-hop 项应返回新 subtitle 和新 imageSrc;若仍旧,检查本地 SpacetimeDB 是否已发布当前 spacetime-module,以及后台是否手动覆盖过入口配置。若缓存路径和 procedure 路径返回不一致,优先怀疑读模型映射没做纠偏,而不是前端展示层。

VectorEngine edits multipart 必须发送真正的文件 part

  • 症状:拼图参考图链路请求 /v1/images/edits 返回 500 image is required,但应用日志里 reference_image_count=1、reference_image_bytes_total>0,request_params.referenceImages[0] 也有 field=image、文件名、MIME 和 bytes。
  • 根因:Rust curl::easy::Form 中 contents(...).filename(...) 不等价于文件上传 part;VectorEngine 转码层会认为没有收到图片。release 上用 curl CLI -F image=@file 可成功,证明字段名和上游接口本身没变。
  • 处理:multipart 参考图必须用 Form::buffer(file_name, bytes) 并设置 content_type(...),让 libcurl 生成真正的 name="image"; filename="..." 文件 part。
  • 验证:核对实际 multipart 文件 part,不能仅凭日志中存在图片字节判断上传格式正确。
  • 关联:server-rs/crates/platform-image/src/vector_engine/curl_transport.rs、docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md。

推荐页 ready 不能只等主图或首次 DOM 图片

  • 现象:移动端推荐页卡面遮罩在作品主图加载后就渐隐,但游戏内 UI 图集、背景、道具图或换签中的 generated 图片还没有准备好,用户会看到运行态半成品或资源闪入。
  • 原因:推荐页 ready probe 如果只扫描首次挂载时已有的 <img>,就会漏掉 React effect、/api/assets/read-url 换签、spritesheet 解析或后续 state 更新才新增的资源。
  • 处理:推荐页 runtime 遮罩必须持续观察运行态 DOM 内新增图片、内联 background-image 和 data-runtime-resource-pending 隐藏标记;各玩法对换签中、解析中的资源源头要暴露 pending 标记,失败后释放标记并交给玩法兜底,避免遮罩永久卡住。
  • 验证:npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "mobile recommend cover waits for async runtime resources beyond the main image|mobile recommend cover waits until runtime images are ready"。
  • 关联:src/components/rpg-entry/RpgEntryHomeView.tsx、src/components/common/RuntimeResourcePendingMarker.tsx、src/components/ResolvedAssetImage.tsx、docs/【玩法创作】平台入口与玩法链路-2026-05-15.md。

iOS 退款问询的 result_code 不是 debug 状态

  • 现象:为了先观察真实 iOS 退款通知,回调返回 ErrCode=0 + IosRefundQueryResponse.result_code=1,并把 evidence 写成“调试阶段不执行自动退款决策”,看起来像安全 ACK,实际已经向微信建议拒绝退款。
  • 原因:xpay_subscribe_ios_refund_query_notify 只有 result_code=0(建议退款)和 1(建议拒绝)两种正式决策;evidence 必须是可审计的履约或消耗事实,不存在中立调试值。与此同时,解密后的完整 payload 含 OpenID、Apple 交易号、退款原因和票据,不能为了排障直接落日志。
  • 处理:未接入真实履约决策时返回非零 ErrCode 让微信重试,不携带 IosRefundQueryResponse;所有事件只写脱敏结构化摘要,payload、未知事件/字段、标识符和字符串值使用消息 Token 加用途域派生的稳定 HMAC 引用,自由文本只写长度和 HMAC 引用。Android 订阅成功和普通 goods 通知可能同形,payload marker 只能快速分流;只有存在同号 wechat_mp_virtual 本地充值订单,并在 2.5 秒内通过 /xpay/query_order 校验订单号、金额、order_type=0/7、支付状态和权威 paid_time,才允许入账。Apple 通知缺少 WeChatPayInfo.PaidTime 时走查单,绝不能用本机时间补齐。
  • 验证:cargo test -p platform-wechat virtual_payment_debug_summary --manifest-path server-rs/Cargo.toml、cargo test -p api-server virtual_payment_debug_routing --manifest-path server-rs/Cargo.toml、cargo test -p api-server virtual_payment_ios_refund_query_has_no_fake_decision_response --manifest-path server-rs/Cargo.toml。
  • 关联:server-rs/crates/platform-wechat/src/pay.rs、server-rs/crates/api-server/src/wechat/pay.rs、docs/【技术方案】微信虚拟支付接入-2026-05-26.md。

微信支付 V3 的支付 notify_url 不会自动接收退款结果或发现全部手工退款

  • 现象:普通微信支付成功回调已经配置并可达,但在商户平台或代码里发起退款后,/api/profile/recharge/wechat/notify 收不到退款单状态变化;商户平台手工退款也可能没有请求本系统的退款回调入口。
  • 原因:V3 支付成功通知与退款结果通知是不同契约;代码发起退款时,退款通知地址来自每次 POST /v3/refund/domestic/refunds 请求里的 notify_url,支付下单使用的 WECHAT_PAY_NOTIFY_URL 不会自动复用。商户平台手工退款不能假设会携带本系统按 API 请求传入的回调地址;退款接口返回成功也只表示受理,不能当成退款终态。
  • 处理:代码退款显式传入公网 https://<API 域名>/api/profile/recharge/wechat/refund-notify,并用稳定 out_refund_no 串联申请、重复通知和主动查单。回调先用原始 body 验签、检查正负 5 分钟时间窗,再用 APIv3 密钥解密;校验事件、资源类型、商户号和退款状态后,将 callback observation 写入统一 SpacetimeDB 事务,持久化成功才返回 204。正式链路不再是“debug 只记日志”:部分 / 全额退款、泥点回收、欠款冻结和会员人工复核均由事务收口;未知事件、校验或持久化失败返回微信 FAIL 响应。另开启 WECHAT_PAY_REFUND_RECONCILIATION_ENABLED=true,对 order_missing / order_not_paid 继续等待晚到支付通知,候选退款按分钟轮转分页且错误日志不回显 provider URL;次日 10 点后按分片补扫微信 API 可查询的近 90 天 bill_type=REFUND 交易账单,并在落账前再主动查单。单行失败不能阻塞其他行或日期,也不能提前写完成 checkpoint;昨日 NO_STATEMENT_EXIST 至少延迟到次日 10 点后再确认;不要为联调开放未鉴权公网退款或补录接口。
  • 验证:cargo test -p platform-wechat v3_refund_notify --manifest-path server-rs/Cargo.toml、cargo test -p platform-wechat v3_transaction_notify --manifest-path server-rs/Cargo.toml、cargo test -p api-server v3_refund_notify_failure --manifest-path server-rs/Cargo.toml;真实联调后只读核对 profile_recharge_refund、profile_recharge_refund_observation、profile_recharge_order_refund_settlement 和 profile_recharge_refund_bill_checkpoint。
  • 关联:server-rs/crates/platform-wechat/src/pay.rs、server-rs/crates/api-server/src/wechat/pay.rs、server-rs/crates/api-server/src/app.rs、docs/【技术方案】微信虚拟支付接入-2026-05-26.md。

已 ACK 的历史退款通知不会因正式落账上线而自动重放

  • 现象:微信侧退款已经是 SUCCESS,旧 debug 回调也曾返回 204,但部署正式退款表和权益回收事务后,本地充值订单仍为 paid,退款表没有记录。
  • 原因:微信收到成功应答后会把该次通知视为已送达;服务升级不会让已经 ACK 的历史通知自动重放。主动 reconciliation 只能继续查询本地已经知道 out_refund_no 的非终态退款,不能凭空枚举所有历史退款。
  • 处理:已知 out_refund_no 时,由持有真实商户凭据的受控服务端先调用单笔退款查询,验微信响应签名后写入统一 observation 事务;未知的商户平台退款等待 T+1 REFUND 交易账单发现,再查单落账。自动账单按分片补扫微信 API 可查询的近 90 天,超过窗口的数据需从商户平台导出候选后逐笔受控查单。禁止用 SQL 直接把订单改为 refunded,也禁止直接插入退款表或按账单 CSV 状态扣泥点,这些做法会绕过不可变字段冲突校验、累计部分退款和权益结算。
  • 验证:核对退款 observation 的 source、resolution_code 与金额,再核对订单级 settlement 的累计退款、recovery_status、unrecovered_points 和 wallet_frozen;全额退款应保留原订单 paid_at,防止错误恢复首充资格。
  • 关联:server-rs/crates/api-server/src/profile_recharge_refund_reconciliation.rs、server-rs/crates/spacetime-module/src/runtime/profile.rs、docs/【开发运维】本地开发验证与生产运维-2026-05-15.md。

退款请求结果未知时不能立即释放钱包占用

  • 现象:后台调用微信退款超时或连接中断,页面提示状态未知;如果服务端立即释放泥点占用,用户可以继续消费,而微信稍后仍可能完成退款,最终形成可避免的退款欠账。反过来,永久保留占用又会让一次明确未创建的退款长期冻结余额。
  • 原因:HTTP 错误只能说明客户端没有拿到确定响应,不能证明微信没有受理;单次退款查单 RESOURCE_NOT_EXISTS 也可能处于短暂传播窗口。只有使用原 out_refund_no 主动查单才能继续判定。
  • 处理:网络结果未知时保留活动 hold,页面把原 requestId、订单、金额、原因和已知 out_refund_no 保存到当前后台标签页、当前管理员会话隔离的 sessionStorage,有效期 2 小时;刷新后必须与后端 active hold 的金额、原因和退款号对账一致才允许直接复用原请求,服务端明确拒绝时清理上下文。没有原请求上下文、上下文过期或管理员会话已切换时只开放预填 out_refund_no 的安全查单登记,不生成新 ID 硬撞活动 hold。worker 在占用创建至少 10 分钟后查同一退款号,查到退款就将验签事实写入统一 observation,只有连续 3 次查单收到官方 RESOURCE_NOT_EXISTS 才释放。进程重启清空连续次数并重新观察;超时、签名、配置、解析等错误一律重置次数并继续占用。
  • 补充:退款查单适配器必须保留微信 RESOURCE_NOT_EXISTS 业务码,并兼容同类 ORDER_NOT_EXIST,不能把所有非 2xx 都抹平成通用上游错误;签名有效的退款申请/查询响应仍须与本次 out_refund_no、订单号、交易号和金额做关联校验。已释放 hold 复用旧 requestId 时必须在调用微信前拒绝,并要求重新预检生成新的请求 ID。
  • 关联:server-rs/crates/api-server/src/admin_recharge.rs、server-rs/crates/api-server/src/profile_recharge_refund_reconciliation.rs、profile_recharge_refund_hold。

时段 UV 不能伪装成单日趋势

  • 现象:访问人数卡显示整段时间有数百人,但趋势图只有终止日一根满柱,其余日期是同样高度的小短柱;横向滚动后还容易误以为后半月突然出现访问。
  • 原因:把时段跨日去重 UV 通过单值 series 塞进 anchor_date_key,再对 0 值强制设置最小可见高度。时段 UV、每日 UV 和每日 UV 之和是三个不同指标,不能互相替代。
  • 处理:趋势 bucket 按 day_key + user_id 每日去重,图头单独使用整个筛选范围跨日去重人数;0 值高度必须为 0。多张同轴图使用同一日期范围并同步横向滚动,快捷范围不生成未来日 bucket。
  • 验证:构造同一用户跨两日访问与某日零访问的 fixture,断言每日 bucket、时段去重总数和零值柱分别正确;浏览器核对四图首尾日期窗口一致。

历史钱包消费不能从最近流水或通用订单快照推算

  • 现象:后台用户详情要展示累计花费时,直接复用只返回最近 50 条的 list_profile_wallet_ledger,或在充值订单每行使用的通用钱包快照里扫描该用户全部流水。
  • 原因:最近流水会低估历史总额;通用钱包快照又会被订单列表反复构造,把一次按用户聚合放大为 订单数 × 流水数 的重复扫描。
  • 处理:历史花费只累计 asset_operation_consume 负向流水绝对值,退款不冲减;通过 profile_wallet_consumption_total 在已有投影时按主键 O(1) 累加。首次上线必须在停写维护窗口由 owner 执行全量初始化,为每个已有钱包流水的用户建立投影,不能让所有存量用户的首次正常消费各自扫描历史;维护遗漏或新用户缺行时才在首次消费或详情读取中按用户索引兜底重建一次。手动对账扫描是独立高风险操作,member 必须单独持有 profile-wallet-consumption-reconcile,不能因为能打开共享用户详情就自动获得。
  • 验证:构造消费、退款、充值退款追回和赠送混合流水,断言只累计消费;维护初始化后正常消费只按主键累加;重复详情读取不得重复扫描或重复累计;任意 Tab 权限不能调用手动对账,同时确认充值订单列表的通用钱包快照没有新增历史流水扫描。

点赞状态的可选鉴权与请求代次不能混入共享缓存或 render

  • 症状:刷新后点赞状态丢失,或换账号后被旧请求回包覆盖。总点赞数不包含当前浏览者身份,公开列表必须通过可选鉴权返回 viewerLiked;匿名固定为 false,账号 scope 改变时重载并丢弃旧回包。
  • 隐藏边界:带 viewer 状态的响应不能进入共享缓存,追加 Vary: Authorization 不得覆盖已有 Vary 字段;无效 Bearer 返回 401 + private,no-store,不能降级成匿名成功。请求 generation 的激活与失效跟随已提交 effect,不能在 render 中改 ref;被 Suspense 放弃的 render 也会留下 ref 副作用,干扰仍在使用的请求。
  • 核验:覆盖换号、旧首屏/分页/写请求迟到,以及被放弃的 render 不影响当前已提交请求。点赞写入以服务端确认结果为准;鉴权身份不能由请求中的 user ID 代替。

2026-09-20 发行网关用 CORP same-origin 会让沙箱内游戏加载不了自己的脚本

  • 现象:平台游玩页的 iframe 明明 onLoad 了(加载遮罩消失、game-player-frame--ready),但控制台出现 net::ERR_BLOCKED_BY_RESPONSE.NotSameOrigin … /releases/<gameId>/assets/app.js,游戏内的脚本从未执行;直接在新标签页打开同一个 index.html 却一切正常,很容易误判成「已经能玩」。
  • 原因:按安全合同 iframe 必须只用 sandbox="allow-scripts"(禁止 allow-same-origin),文档因此是不透明来源(opaque origin)。此时它对同包资源的请求不再与网关同源,而响应上的 Cross-Origin-Resource-Policy: same-origin 会把请求判为跨来源并拦下;ES modules 还会额外走 CORS,需要 Access-Control-Allow-Origin。
  • 处理(现行口径):发行网关的公开静态响应使用 Cross-Origin-Resource-Policy: cross-origin 与不带 credentials 的 Access-Control-Allow-Origin: *,继续保留 X-Content-Type-Options: nosniff、内容类型白名单、HTML 最小权限 CSP 和「带 Cookie 一律 403」。这些都是公开静态文件,放宽 CORP/CORS 不暴露凭据;容器隔离靠沙箱、CSP 与独立来源,不靠 CORP。
  • 验证方式:不要只用 onLoad 判断可玩。要在真实浏览器里点「开始游戏」,确认控制台没有 ERR_BLOCKED_BY_RESPONSE/CSP 报错,并核对 api-server 访问日志里该版本资源的 http.response.status_code=200。
  • 关联:server-rs/crates/api-server/src/modules/game_distribution.rs(release_asset_response)、src/components/game-distribution/GamePlayPage.tsx、docs/【玩法创作】平台入口与玩法链路-2026-05-15.md。

2026-09-28 前端运行时配置接口用 skipAuth 调用,导致显式配置发布灰度后网页端永远没有发布入口

  • 现象:运营把 game-distribution:publish 显式配置成 enabled=true, rolloutPercent=100 后,登录作者在网页 /games/publish 仍看到「发布功能正在灰度中 / 当前账号还没有发布入口」,但用同一账号的 access token 直接请求 /api/runtime/frontend-config 得到 gameDistributionPublishEnabled=true。
  • 根因:src/services/frontendRuntimeConfigService.ts 的 loadFrontendRuntimeConfig() 传了 skipAuth: true,请求不带登录态;而发布灰度是按作者判定的(匿名恒为 false),所以壳层拿到的永远是匿名结果。
  • 处理:去掉 skipAuth(保留 skipRefresh 等选项),登录后壳层按 gamePublishGateUserId 重读配置即可拿到发布入口;frontendRuntimeConfigService.test.ts 与 check:game-distribution-web-publish-e2e 都覆盖回归。教训:任何「按用户/作者灰度」的配置接口都不能走 skipAuth,匿名结果与登录结果语义不同。

2026-09-29 客户端"本地导出上限"不等于"平台发布上限"

  • 现象/风险:AGC 里 MAX_PROJECT_EXPORT_PACKAGE_BYTES = 512 MiB 管的是"本地导出/暂存的体积安全线",而平台发布上限是 200 MiB(module-game-distribution 的 MAX_PACKAGE_BYTES,2026-09-23 决策从 100 MiB 放宽而来)。两者不是一个概念,拿前者当发布前检查会让 200–512 MiB 的包一路读盘 + 暂存,最后在建版本时吃服务端 413,作者白等一轮。
  • 现状(2026-09-29 已修):上限单一来源落到 shared_contracts::game_distribution::GAME_DISTRIBUTION_MAX_PACKAGE_BYTES;AGC 在读包阶段就做 ensure_within_platform_package_limit() 预检并给出「发行包 X MiB 超过平台上限 200 MiB;请精简资源后重新导出再发布」。服务端领域 crate 不依赖 shared-contracts,仍保留自己的常量,由 api-server 的 publish_package_limit_matches_the_shared_contract 锁成一致。
  • 判据:任何"客户端先检查、服务端再校验"的额度都要问一句"这两处是同一个数吗、谁保证不漂移";本地安全线(防呆)与平台业务额度(可对外承诺)要分开命名,别混用。

iframe onLoad 不等于游戏可玩,压缩协商须核对源站请求

  • 黑屏误判:iframe onLoad 只表示文档已加载,不能证明游戏脚本执行或资源就绪。共享加载面提供慢加载、超时和恢复入口;验收还要在真实浏览器核对脚本执行、资源响应及控制台错误。
  • 压缩误判:Pingora 关闭自身压缩时可能移除请求的 Accept-Encoding,浏览器支持 gzip 也不代表源站收到了协商头。排查须比较边缘与源站实际请求、响应,避免重复编码;大文件实时压缩还会占用公开端点 CPU。
  • 缓存边界:公开资源 ETag 按版本与路径隔离,gzip 带 Vary: Accept-Encoding;审核预览继续 no-store 且不发 ETag,撤销窗口不能被条件请求放宽。具体合同见玩法链路。

Router 用户分组与 Key 分组控制不同边界

账号分组控制付费档位,Key 分组决定可用渠道。把二者都固定为 taonier,可能让账号已有权限的另一种协议渠道仍不可用,表现为原生 Anthropic 请求失败或回退桥不能执行工具。

当前 Router 用户保持 LLM_ROUTER_USER_GROUP = "taonier",Key 创建与修复使用 LLM_ROUTER_TOKEN_GROUP = "auto"。排查时分别核对用户、Key 和渠道,不因用户组正确就排除路由问题。模型目录启动同步使用用户分组 LLM_ROUTER_USER_GROUP(taonier),不能复用 Token 的 auto 路由分组,否则调整 Key 路由会意外改变在售目录;目录请求测试须固定断言 group=taonier。合法的存量目录不会自动重建,不能把 Key 修复成功当作目录已更新。入口为 external_api_keys.rs 与 agc_models.rs。

模型目录协议变更必须同步回写客户端选中项

  • 现象:后台把同一个模型目录项从 Codex/Responses 改成 Claude Code/Anthropic 后,客户端仍把该模型发到 /v1/responses,Router 最终以 500 not implemented 失败;模型列表本身已经显示新协议。
  • 根因:模型选择器原先只在 selectedModelId 或默认项变化时写回配置,没有比较同一 ID 的 protocol。已选模型保留旧的 selectedModelProtocol 和 agentMode,直到用户重新点选。
  • 现行口径:目录同步发现协议漂移时,按目录中的 agentMode / protocol 重写选中项;旧配置缺省协议仍按 openai_responses 兼容,非 Responses 或已有显式协议不一致才触发写入。回归测试覆盖同一模型 ID 从 Codex/Responses 切到 Claude Code/Anthropic。
  • 关联:ConversationModelSelect.tsx、apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx。

跨领域与待归类

资源预览的 scope、容量与队列边界不能靠浅层文本测试验证

  • 症状与机制:旧 scope 的请求 finally 扣减新 scope 计数,会破坏新会话并发;只限条数的 base64 缓存仍可能超出字节预算;无优先级有界队列会让主动预览被预取饿死。React 合成 wheel 又可能未真正阻止 WebView 默认缩放。
  • 处理:项目/mode 切换推进 epoch,旧请求不得修改新 scope;data URL 仅作临时传输,转成可撤销 Blob URL,LRU 同时限数量和总字节。主动请求替换低优先级预取;用原生可取消事件核对 defaultPrevented,媒体播放同时核对可见集合,内外滚动按各自 scope 保存。
  • 坐标边界:共享 TS 与 Rust 使用同一合法域,超深布局在 IPC 前拒绝非法坐标,不反复提交必然失败的写入;暂时/永久错误显式分类。
  • 关联:useProjectResourceCardPreviews.ts、resourceCanvasLayoutModel.ts、src-tauri/src/project/resource_layout.rs;当前资源管理合同以 AGC 实施计划为准。

Rust 大文件拆成嵌套模块后要同时核对路径、可见性和兼容重导出

  • 根因:pub(super) 指直接父级,下沉文件会缩小可见范围;更近的同名子模块会遮蔽 crate 根路径。Rust 子模块也不继承父模块私有 use,测试下沉后必须显式 import 自身 helper/trait。
  • 处理:拆分前记录公开符号、可见性和测试路径。兄弟子模块 helper 最小使用 pub(super),确需跨 facade 时才使用适当 pub(in ...),不为测试统一放宽为 pub(crate);根模块用显式 crate::...。兼容重导出须按现役调用面/契约判断,必要出口仅局部 #[allow(unused_imports)],不按 warning 机械删除或全局 suppress。
  • 验证:写入完成后的稳定树核公开 API、原测试名与路径,运行对应 crate 编译和测试;宿主缺交叉工具导致未进入项目代码时明确标记未验证。
  • 关联:Rust 模块拆分适用;已退役Runtime文件不再作为保留兼容出口的依据。

lint-staged 会改变已经审查过的暂存内容

  • 触发与机制:共享文件暂存并审查后,lint-staged 会格式化并重新暂存文件;最终索引可能已不同于此前审查,--name-only 或 M /MM 状态不能证明 hunk 归属。
  • 处理:hook 后按最终 git diff --cached 逐 hunk 核对;发现无关 hunk 先协调,再调整暂存并保留所有工作区改动。
  • 关联:package.json 的 lint-staged 配置、.husky/pre-commit。

2026-09-22 自动合并"无冲突"也可能静默拼坏 CSS 分组选择器

  • 现象:合并 master 时 git 报告 0 冲突,但 apps/ai-game-creator-shell/src/styles.css 里新加的头条规则被并进了 master 策划态分组选择器的中间——.game-workbench-layout--design .project-chat-topbar-status, 后面直接跟了别的选择器,策划态的 font-size / color 等声明整块丢失;类型检查与多数用例都不受影响,只有 chatDialogFrameLayout 这类 CSS 级联用例报"缺少生效声明"。
  • 原因:双方在同一分组选择器附近各自插入规则时,hunk 可以"兼容"地拼在一起,git 不会报冲突,但选择器列表被拆散。
  • 处理:改完 CSS 后按 master 原文重建分组规则、把新增规则独立成块;顺手删掉重复规则时要用带上下文的精确片段,避免删到分组选择器的第二个选择器。

Vitest 转译通过不代表纯类型契约已验证

  • 症状与机制:合并后的纯类型文件可能未进入实际用例执行链,或只经过 esbuild 类型剥离;Vitest 全绿不能证明它的类型契约正确。
  • 处理:冲突块位于接口、函数或 test 调用内部时,先恢复完整语法骨架再插入另一侧增量;对最终树运行对应 typecheck,并定向运行受影响的测试,不能用转译通过或格式检查替代。
  • 关联:npm run admin-web:typecheck;AGC 测试的类型门禁见 development-workflow.md。

自动合并可能错挂 cfg(test),cargo check 也覆盖不到测试构建

  • 症状与机制:整文件取 ours 会丢掉上游新增 Tauri command,表现为缺 __tauri_command_name_*;自动合并把上游 #[cfg(test)] 贴到本分支新函数前,会只在非 test 构建报 unresolved import;上游删除实现也可能仅在 test 构建暴露引用残留。
  • 处理:按完整调用链核对命令与条件编译边界,不把无冲突当成语义正确。保留自动合并增量、只重建实际冲突结构;普通 cargo check 后追加 cargo test --no-run,并检查 fmt/换行,覆盖两种编译面。

2026-10-07 发布 DTO 里同时出现路径身份字段:versionNumber 到底听路径还是听 body

  • 现象:把游戏分发发布面收敛成 POST /games 与 POST /games/{game_id}/versions/{version_number} 后,NewGameVersionRequest 里一度仍保留 gameId / versionNumber,GameDistributionCreateVersionRequest 也带 versionNumber。于是同一个事实有两个来源:路径里的 {version_number} 与 body 里的 versionNumber。handler 必须写「哪个优先、不一致怎么办」的分支,客户端也会猜「传 body 是不是能覆盖路径」——既多一处校验,又多一类只能靠运行时发现的不一致。
  • 根因:沿用了「请求体自带完整身份」的旧习惯(旧两步 POST /games/{gameId}/versions 的 body 必须带 versionNumber,因为没有路径槽)。REST 子里路径已经是资源身份,body 再写一份就变成第二个真源。
  • 现行口径:路径即身份,body 不得重复路径参数。只要路径里有 {game_id} / {version_number},handler 只从 Path(…) 取值,不存在「路径优先还是 body 优先」的校验分支;NewGameVersionRequest 因此去掉 gameId 与 versionNumber(POST /games 无路径槽,只带 projectKey,首版号固定 1;追加版本的身份全在路径)。同一轮复核其余带路径参数的写端点(submit / cancel / purchase / reviews / theme members / admin review),请求体都不含路径身份字段。
  • 验证:cargo test -p shared-contracts publish(publish_version_request_body_has_no_path_identity)、cargo test -p api-server game_distribution、npm run check:game-distribution-dto-parity、git diff --check。
  • 关联:docs/adr/【ADR】游戏分发版本以versionNumber为对外身份-2026-10-07.md(「路径即身份(body 不重复路径参数)」)、server-rs/crates/shared-contracts/src/game_distribution_publish.rs、server-rs/crates/api-server/src/modules/game_distribution.rs、apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs。