修复游戏场景队列回读时序

重读未收口的场景权威快照

补充场景队列完成回归测试

同步场景链路与排障约定
This commit is contained in:
2026-08-06 14:42:13 +08:00
parent 447c23840c
commit a0b139ba34
4 changed files with 116 additions and 0 deletions
@@ -29,6 +29,7 @@
- 处理:把递增封进一个 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`)。
## 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` 的系统别名误判为用户符号链接。
@@ -287,6 +288,14 @@
- 验证:画板生成 workflow 测试覆盖 queueState 持续 `running` 到前端等待窗口结束时,不进入 failed、不显示该排队文案、不添加本地临时结果层。
- 关联:`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts``src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx`
## 场景队列终态不保证首次项目快照已经收口生成占位
- 现象:游戏场景任务已经显示完成,但画布仍保留 `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 快照直接关闭。
@@ -3974,6 +3983,7 @@
- 处理:灰度页只能以 `/admin/api/feature-gates` 为数据源,固定目标列表只登记现役功能;新增或退役业务 target 只修改固定目标注册,不得让通用页面依赖业务列表接口。旧 `creation-entry:*` 目标、接口和页面保持退役。
- 验证:`adminRoutes` 必须包含 `gray-release`admin-web TypeScript/ESLint/Vitest 不得排除灰度页;页面测试必须断言只请求 feature-gates,并继续覆盖现役固定 target、直接 Gate Key 保存与新 target 状态重置。
- 关联:`apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx``apps/admin-web/src/app/adminRoutes.ts``server-rs/crates/api-server/src/modules/admin.rs``docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md`
## 历史钱包消费不能从最近流水或通用订单快照推算
- 现象:后台用户详情要展示累计花费时,直接复用只返回最近 50 条的 `list_profile_wallet_ledger`,或在充值订单每行使用的通用钱包快照里扫描该用户全部流水。
@@ -4096,6 +4106,7 @@
- 处理:从当前 root source 的 seed lane 动态解析全部零依赖首波任务,只对这些 child 容忍 hydration `Pending`,后续 code prototype / preview 仍严格要求 Running/Completed。`streaming / ready` 仍要求当前 revision`committed` 回复改为依据 finalization 的稳定身份查询,不随后续项目 revision 失效。
- 验证:覆盖 `design-director / art-director / code-director` 三个 Pending 首波 child 均可投影 Completed、`code-prototype` Pending 仍被拒绝;非流式专业 Agent 在 finalization 前无 stream,提交后形成 committed stream,再推进项目 revision 后仍可查询且正文不变。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/response_stream.rs`
## 异步生成结果未知时不能换幂等键重提(2026-07-31)
- 现象:生成提交发生客户端超时、连接中断或响应丢失后,调用方创建新的 `Idempotency-Key` 再提交一次;原任务其实已经入队,最终造成重复生成、重复扣费和重复画布 / 素材库写入。
@@ -132,6 +132,7 @@ prompt = 后端完整 Prompt
- Provider 失败、执行取消和 lease 耗尽沿用现有扣退费语义。
- Provider 成功后的持久化或画布写回失败语义不在本期调整。
- inline 响应中的通用生成告警必须在队列终态处理前转发;有项目画布占位但响应缺少权威 `project` 快照时,不得追加本地图层或把占位标记为已完成。
- 队列任务进入 `completed` 后,首次项目快照仍可能短暂保留同一 `dialogId``generating` 占位。场景提交必须把 completion 的 `dialogId` 传给统一项目回读逻辑;首个权威快照仍未收口时做一次有界延迟重读,不能把该快照当作最终状态永久套用。
## 7. 数据落点
@@ -1728,6 +1728,109 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
);
});
it('reloads the authoritative scene project when the first queued snapshot is unresolved', async () => {
vi.useFakeTimers();
try {
const applyProjectSnapshot = vi.fn();
const unresolvedProject = {
projectId: 'editor-project-1',
title: '场景队列项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'generation-dialog-scene-queued',
resourceId: 'generation-dialog-scene-queued',
itemType: 'generation-dialog',
dialog: {
id: 'dialog-scene-queued',
mode: 'scene',
status: 'generating',
},
},
],
resources: [],
updatedAt: '2026-08-06T08:00:00.000Z',
};
const completedProject = {
...unresolvedProject,
layers: [
{
layerId: 'layer-scene-result',
resourceId: 'resource-scene-result',
title: '游戏场景 1',
assetKind: 'scene',
},
{
layerId: 'generation-dialog-scene-queued',
resourceId: 'generation-dialog-scene-queued',
itemType: 'generation-dialog',
dialog: {
id: 'dialog-scene-queued',
mode: 'scene',
status: 'idle',
generatedLayerId: 'layer-scene-result',
},
},
],
updatedAt: '2026-08-06T08:00:01.000Z',
};
generateEditorSceneMock.mockResolvedValueOnce({
...createGenerated({ width: 1024, height: 576 }),
queueState: createQueueState(),
});
loadEditorProjectMock
.mockResolvedValueOnce(unresolvedProject)
.mockResolvedValueOnce(completedProject);
render(
<SubmissionWorkflowHarness
projectId="editor-project-1"
applyProjectSnapshot={applyProjectSnapshot}
initialDialog={{
id: 'dialog-scene-queued',
mode: 'scene',
prompt: '雨夜海边车站',
status: 'idle',
composerOpen: true,
sceneStylePreset: 'anime',
sceneCustomStyle: '',
generationReferences: [],
imageModel: 'gemini-3.1-flash-image-preview',
aspectRatio: '16:9',
imageSize: '1K',
placeholder: {
x: 100,
y: 100,
width: 1024,
height: 576,
originalWidth: 1024,
originalHeight: 576,
},
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(loadEditorProjectMock).toHaveBeenCalledTimes(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(650);
});
expect(loadEditorProjectMock).toHaveBeenCalledTimes(2);
expect(applyProjectSnapshot).toHaveBeenNthCalledWith(
1,
unresolvedProject,
);
expect(applyProjectSnapshot).toHaveBeenNthCalledWith(2, completedProject);
} finally {
vi.useRealTimers();
}
});
it('does not add a local scene layer when the backend omits its project snapshot', async () => {
generateEditorSceneMock.mockResolvedValueOnce(
createGenerated({
@@ -2091,6 +2091,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
onGenerationWarning,
canvasDialog?.id,
)
) {
return;