diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index b607dc7f1..21f212f11 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -32,6 +32,33 @@ const PLATFORM_ART_SPRITESHEET_MAX_DIMENSION: u32 = 4_096; const PLATFORM_ART_SPRITESHEET_TOTAL_PIXELS: u64 = 16 * 1024 * 1024; const PLATFORM_ART_SPRITESHEET_MAX_DECODE_ALLOC: u64 = 64 * 1024 * 1024; +/// 客户端接受的图集切片数量上限。 +/// +/// 这个值**不是**客户端自己的预算,而是与另外三处取同一个值的一致性约束: +/// 平台切分上限(`server-rs/crates/api-server/src/editor_project_icon.rs` 的 +/// `EDITOR_ICON_SPRITESHEET_MAX_SLICES = 256`)、Agent 工具 schema 的 `sliceCount`(1..256, +/// 见 `agent_native_tools.rs` 与 `direct_tool_bridge.rs`),以及公开契约 +/// (`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`:单边最多 4096 像素、总像素最多 +/// 2048×2048、最多 256 个输出)。 +/// +/// 客户端一旦比平台更严,平台**合法**产出的 65~256 片结果会在绑定结果这一步被整条丢弃: +/// 用户只会看到「异步生成完成结果无法绑定到 operationId」加一句切片超限,既拿不到图也没法自查。 +/// 现场(`gameagent-6e53c9e8`,`connected-components` 自动切分)就是被旧值 64 挡下的。 +pub(crate) const PLATFORM_ART_SPRITESHEET_MAX_SLICES: usize = 256; + +/// 平台返回的图集切片数量是否超出上限(两个结果绑定入口共用同一条判据)。 +pub(crate) fn platform_art_spritesheet_slice_count_exceeds_limit(slice_count: usize) -> bool { + slice_count > PLATFORM_ART_SPRITESHEET_MAX_SLICES +} + +/// 切片数量超限的拒绝文案(两处入口同一句话,数字来自常量,避免再和上限漂移)。 +pub(crate) fn platform_art_spritesheet_slice_limit_error( + source_label: &str, + suffix: &str, +) -> String { + format!("{source_label}的图集切片超过 {PLATFORM_ART_SPRITESHEET_MAX_SLICES} 个{suffix}") +} + type ExternalCanvasBindingAsyncLock = tokio::sync::Mutex<()>; const EXTERNAL_CANVAS_BINDING_PARTIAL_SCHEMA_VERSION: &str = "game-creator-external-editor-project-binding-partial.v2"; @@ -2365,8 +2392,11 @@ async fn prepare_platform_art_spritesheet_slices( else { return Ok(Vec::new()); }; - if icons.len() > 64 { - return Err("External Editor 返回的图集切片超过 64 个,已拒绝同步".to_string()); + if platform_art_spritesheet_slice_count_exceeds_limit(icons.len()) { + return Err(platform_art_spritesheet_slice_limit_error( + "External Editor 返回", + ",已拒绝同步", + )); } let mut prepared = Vec::with_capacity(icons.len()); if initial_download_bytes > PLATFORM_ART_SPRITESHEET_TOTAL_DOWNLOAD_BYTES @@ -8394,6 +8424,26 @@ pub(crate) fn build_platform_art_asset_prompt( #[cfg(test)] mod canvas_generation_tests { + #[test] + fn spritesheet_slice_limit_matches_platform_and_tool_contract() { + // 现场回归(`gameagent-6e53c9e8`):平台按 `connected-components` 自动切图,客户端旧门 + // (>64 就拒)把 65~256 片的**合法**产出整条判失败。这里把上限钉在与平台切分、Agent + // 工具 schema `sliceCount`(1..256)和公开契约同一个值上。 + assert_eq!(PLATFORM_ART_SPRITESHEET_MAX_SLICES, 256); + assert!(!platform_art_spritesheet_slice_count_exceeds_limit(0)); + assert!(!platform_art_spritesheet_slice_count_exceeds_limit(64)); + assert!(!platform_art_spritesheet_slice_count_exceeds_limit( + PLATFORM_ART_SPRITESHEET_MAX_SLICES + )); + assert!(platform_art_spritesheet_slice_count_exceeds_limit( + PLATFORM_ART_SPRITESHEET_MAX_SLICES + 1 + )); + // 拒绝文案里的数字来自常量本身,不会和上限再次漂移。 + assert_eq!( + platform_art_spritesheet_slice_limit_error("External Editor 返回", ",已拒绝同步"), + "External Editor 返回的图集切片超过 256 个,已拒绝同步" + ); + } use super::*; use image::{ codecs::png::{CompressionType, FilterType, PngEncoder}, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs index c88a8a19c..f2332c009 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs @@ -912,8 +912,14 @@ fn durable_legacy_generation_result( .get("iconImageSrcs") .and_then(serde_json::Value::as_array) { - if icons.len() > 64 { - return Err("External Editor 旧同步结果的图集切片超过 64 个".to_string()); + if super::canvas_generation::platform_art_spritesheet_slice_count_exceeds_limit(icons.len()) + { + return Err( + super::canvas_generation::platform_art_spritesheet_slice_limit_error( + "External Editor 旧同步结果", + "", + ), + ); } let mut durable_icons = Vec::with_capacity(icons.len()); for (index, icon) in icons.iter().enumerate() { @@ -1995,6 +2001,94 @@ mod external_generation_state_tests { ); } + /// 现场回归(`gameagent-6e53c9e8`,2026-09-21):平台按 `connected-components` 自动切图, + /// 切出的数量落在 65~256 之间是**合法**产出;客户端旧门(>64 就拒)会把整条结果判失败, + /// 用户只看到「异步生成完成结果无法绑定到 operationId」——图没落盘,付费结果被丢弃。 + /// + /// 上限必须与平台切分(`EDITOR_ICON_SPRITESHEET_MAX_SLICES = 256`)和 Agent 工具 schema + /// 的 `sliceCount`(1..256)同值;超过上限仍然拒,且文案里的数字来自常量本身。 + #[test] + fn legacy_result_accepts_slice_counts_up_to_platform_limit_and_rejects_beyond() { + fn slice(index: usize) -> serde_json::Value { + serde_json::json!({ + "name": format!("素材 {index}"), + "width": 64, + "height": 64, + "resource": { + "resourceId": format!("slice-{index}"), + "objectKey": format!("generated/slice-{index}.png") + } + }) + } + + fn persist_legacy_slice_result( + root: &Path, + count: usize, + ) -> Result { + let pending = pending_canvas_generation(root); + let context = platform_art_generation_runtime_context_from_pending(&pending); + let access = developer_access("https://editor.example.test", "test-api-key"); + let request_body = serde_json::json!({ + "prompt": "生成统一视觉规范图", + "kind": "spec", + "projectId": "canvas-project", + "assetFolderId": "asset-folder", + "referenceImageSrcs": [] + }); + let (state, _) = prepare_platform_art_generation_runtime_state( + root, + &context, + "/api/external/v1/editor/images/generations", + "legacy-generation-canvas", + "生成统一视觉规范图", + &request_body, + &access, + ) + .expect("prepare legacy generation ledger"); + let completed = mark_platform_art_generation_runtime_legacy_completed( + root, + state, + &serde_json::json!({ + "resource": { + "resourceId": "legacy-resource", + "objectKey": "generated/legacy.png" + }, + "iconImageSrcs": (0..count).map(slice).collect::>() + }), + )?; + remove_platform_art_generation_runtime_state_at( + root, + &pending.agent_id, + &pending.run_id, + ) + .expect("reset legacy ledger between slice counts"); + platform_art_generation_runtime_legacy_result(&completed) + .map_err(|error| error.to_string()) + } + + let temporary = crate::tests::canonical_test_tempdir("legacy-generation-slice-limit-"); + let root = temporary.path(); + init_local_game_project_at(root, "legacy-slice-limit", "旧同步切片上限回归") + .expect("init project"); + + // 64(旧上限)到 256(平台上限)之间都是合法产出:必须能持久化,切片一条不少。 + for count in [64_usize, 65, 256] { + let durable = + persist_legacy_slice_result(root, count).expect("persist legal slice count"); + assert_eq!( + durable["iconImageSrcs"].as_array().map(Vec::len), + Some(count) + ); + } + + // 257 片仍然拒:与平台同一句口径,数字来自常量。 + let error = persist_legacy_slice_result(root, 257).expect_err("reject beyond limit"); + assert!( + error.contains("图集切片超过 256 个"), + "unexpected rejection message: {error}" + ); + } + #[cfg(unix)] #[test] fn dangling_generation_ledger_symlink_is_reconciliation_evidence() { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 411f0a3c8..54bb65a08 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,14 @@ # 决策记录 +## 2026-09-21 图集切片上限:客户端结果门从 64 对齐到平台契约的 256 + +- 背景:现场(项目 `gameagent-6e53c9e8`,2026-09-21 07:54)「AI 生成图标素材」失败:`platform-generation-result-unknown: 异步生成完成结果无法绑定到 operationId:External Editor 旧同步结果的图集切片超过 64 个`。任务账本(`.agent/runtime/asset-generation-tasks/tasks.json`)显示它跑了 99 秒、`assetId` 为空、没有落任何素材;对应的持久化请求(`canvas-generation-requests/manual-canvas-asset-generate/slot-560175669f….json`)是 `sliceMode: connected-components` + `sliceCount: null`(自动切分)。也就是**平台已经生成并切完图了,是客户端在绑定结果这一步把整条结果判失败**,付费产物被丢弃。 +- 根因:同一条链路里存在两个不同的切片上限。平台切分是 256(`server-rs/crates/api-server/src/editor_project_icon.rs` 的 `EDITOR_ICON_SPRITESHEET_MAX_SLICES`),Agent 工具 schema 的 `sliceCount` 是 1..256(`agent_native_tools.rs` / `direct_tool_bridge.rs`),持久化产物批次也是 1..256,公开契约(`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`)写的更是「最多 256 个输出」;唯独客户端**两处结果绑定门**还是 `> 64` 就拒(`agent/generation/external_generation_state.rs` 与 `agent/generation/canvas_generation.rs`,由 `602723ea0` 于 2026-08-03 引入)。自动切分落在这个窗口里(65~256 片)时,客户端比平台更严,于是把合法产出整条丢掉。 +- 决策:两处门统一到 `PLATFORM_ART_SPRITESHEET_MAX_SLICES = 256`,并抽成同一条判据 `platform_art_spritesheet_slice_count_exceeds_limit` 与同一句拒绝文案 `platform_art_spritesheet_slice_limit_error`(数字由常量插值,不再手写)。注释里点名三处同值权威(平台切分常量、工具 schema `sliceCount`、公开契约),客户端不得比平台更严。 +- 原因:客户端这两处门的作用是「防止把不可信/超预算的结果写进本地」,不是产品上限;真正的产品上限属于平台切分契约。两处各写一个字面量就会再次漂移,所以值只留一份、判据只留一条。 +- 验证:新增 `canvas_generation_tests::spritesheet_slice_limit_matches_platform_and_tool_contract`(上限值、边界判据与文案)与 `external_generation_state_tests::legacy_result_accepts_slice_counts_up_to_platform_limit_and_rejects_beyond`(64/65/256 片必须能持久化且切片一条不少、257 片必须按同一句文案拒绝),两条都用**变异验证**确认过:把常量改回 64,回归用例立刻变红。定向执行 `cargo test -- spritesheet`(22 passed)、`cargo test -- external_generation_state_tests::`(10 passed)与两条新用例;`cargo fmt --check` 干净。 +- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/generation/{canvas_generation.rs,external_generation_state.rs}`(+用例)。未动平台切分、OpenAPI、数据库或前端。 +- 已知未覆盖:真实客户端复验(重新生成一次图标素材)与远程 CI 未跑;256 片时的累计下载/像素预算未实测——平台自己的总像素上限是 2048×2048,客户端预算是 4096²,按切片是整图互不重叠子矩形推算不会先撞预算,且真撞了也只是给出明确错误而不是损坏数据。 ## 2026-09-21 本批自查(PR #441):三处修正 - 背景:推 PR 后按「局部到整体」自查这一批(三需求 + 验收修正),查出三条:①拖动到对话的落点在 `pointermove` 上每帧都 `setState` 一个新对象;②替换面板相对 **stage** 写死 `top: 8.5rem`(与刚修的任务开关同一类隐患:工具条换行会压上去),且它和「生成任务」面板抢画布右上角同一个位置;③替换面板不显示「在替换哪张源素材」,而非模态化之后那点线索(画布上的源素材光环)会被一次空白点击清掉。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 65f1c62e0..51ed5f002 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -4,6 +4,13 @@ Copy Artifact 插件在**非 SYSTEM 认证**下按「认证用户」判权:只有当被复制 Job 的 `CopyArtifactPermissionProperty`(仓库里由 Declarative 的 `copyArtifactPermission(...)` 维护)显式列出当前消费者,或者该 Job 对认证用户开放 Item.Read 时才放行;`ACL.SYSTEM2` 的定时构建会短路通过。因此会出现「定时调度一路成功、手动发布必挂」的现象(2026-09-21 手动发布 #6/#7 与同期的用户触发探测全部命中,定时调度 #104+ 正常)。`Genarrative-Agc-Global-Version-Issue` 生产权限模式的授权名单必须同时包含 `Genarrative-Scheduled-Revision-Trigger` 与 `Genarrative-Manual-Build-And-Deploy`;改完 `copyArtifactPermission` 后要先跑一次发号 Job 把 Job property 写回 Jenkins,只改仓库文件不生效。 +## 同一条链路两处上限不一致:平台合法产出被客户端整条丢弃 + +- 现象:客户端报「生成素材失败:platform-generation-result-unknown: 异步生成完成结果无法绑定到 operationId:External Editor 旧同步结果的图集切片超过 64 个」,而平台侧这次生成**其实已经成功并切完图**(任务账本耗时正常、`assetId` 为空、没有任何素材落盘,付费产物被丢)。 +- 成因:图集切片上限在链路里存在两份字面量——平台切分、Agent 工具 schema `sliceCount` 与持久化产物批次都是 256,客户端结果绑定门写着 64(`agent/generation/{canvas_generation.rs,external_generation_state.rs}`)。自动切分(`connected-components` + `sliceCount=null`)切出 65~256 片是合法产出,客户端比平台更严就会把结果整条判失败。 +- 处理:客户端门统一到 `PLATFORM_ART_SPRITESHEET_MAX_SLICES = 256`,判据与文案各只留一份(数字由常量插值),并在注释里点名三处同值权威(平台切分常量、工具 schema、公开契约)。 +- 复用判据:凡是「平台产出 → 客户端校验后落盘」的链路,客户端门只能表达**安全 / 预算**约束,不得比平台的产品上限更严;两边上限要引同一个常量或同一份文档,改一边时必须同时改另一边,并补一条「上限之内必须能落盘」的回归用例。 + ## 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 不必另装。 diff --git a/scripts/check-production-ops-guardrails.mjs b/scripts/check-production-ops-guardrails.mjs index 25efdcf94..323a17e6a 100644 --- a/scripts/check-production-ops-guardrails.mjs +++ b/scripts/check-production-ops-guardrails.mjs @@ -44,8 +44,7 @@ const checks = [ }, { file: 'jenkins/Jenkinsfile.agc-global-version-issue', - includes: - "copyArtifactPermission('Genarrative-Scheduled-Revision-Trigger,", + includes: "copyArtifactPermission('Genarrative-Scheduled-Revision-Trigger,", reason: 'Copy Artifact Production 模式下,AGC 发号 Job 必须显式授权调度管线读取 agc-global-version.txt,否则整轮调度会在 copyArtifacts 处失败。', },