完美像素未知结果先对账再定性
Project CI / Repository checks (pull_request) Failing after 47s
Project CI / Frontend tests (pull_request) Successful in 2m53s
Project CI / Backend tests (pull_request) Successful in 3m32s
Project CI / Native shell tests (pull_request) Failing after 7m31s

catch 此前对所有错误一视同仁:标 failed、finally 解锁、按钮恢复可点。transport
异常、abort 和 120 秒客户端超时因此被谎报成明确失败,而服务端此时很可能已经完成
OSS PUT、asset object、project resource、账号素材和画布回填,只是响应没回来。用户
按提示重试就再造一整份。这违反本功能自己立下的契约:结果未知时先 GET 权威快照,
由用户显式决定是否再次执行。

判别依据是 ApiClientError 只在拿到 Response 时由 buildApiClientError 构造,
transport 异常、AbortError 和 TimeoutError 在重试判定后原样抛出。已知结果不发
对账 GET,避免每个 400 都多打一次权威读取。

未知结果先 loadEditorProject,再按占位是否存活分流:占位已被 completion 消费掉
说明这次其实成功,按快照收口并写入正常的 perfect-pixel 历史;占位仍在说明画布没
收到结果,同步快照但不写历史,文案要求先核对素材库再决定是否重试——持久化是非
事务的,对象和素材可能已落库而画布回填未完成。对账 GET 本身失败时给独立文案,
不退回谎报。

既有用例 keeps a failed perfect-pixel placeholder 原本用裸 Error 表达「服务端识别
不到网格」,语义不准且会误入对账路径,改为 ApiClientError。harness 新增
dialog-error 输出,否则对账文案不可观测。

刷新后停在 generating 的占位仍无自动收口,需要先给 dialog 增加「无 durable job 的
inline 链路」标记才能在 hydration 后对账,改动面大于本次,单独立项。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 11:44:57 +00:00
parent 483201b625
commit cc88564d4a
4 changed files with 220 additions and 4 deletions
@@ -5962,3 +5962,12 @@
- Runtime 恢复确认:GUI 自动扫描 `agent.resume` 前必须先用只读方式判断是否存在可恢复任务或 durable recovery artifact;全新项目与已完全终态、无任何恢复工作的项目直接返回空结果,不弹出“恢复未完成 Runtime 任务”;一旦存在 task、retry、handoff、finalization、pending action 或 reconciliation 等可恢复工作,仍必须经过原 `agent.resume` policy 门禁,不得通过吞掉 policy error 绕过确认。
- 每条输出入聊天:事件文件中的原始 `summary / detail` 仍是私有 Runtime 证据,不可由前端直接持久化。Rust 只对白名单用户进度生成 `publicText`,同时为每次真实追加生成 `eventId`action 重放沿用 action 身份,普通事件使用进程、毫秒与单调序列组成唯一身份。前端把 `eventId + publicText` 和三阶段专业 Agent 的 durable final reply 作为独立 assistant 消息,按顶层 `messageId` 幂等写入项目 conversation;重载恢复、轮询与实时事件并发不得重复或漏掉当前已观察输出。
- 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md``docs/project-memory/shared-memory/development-workflow.md`
## 2026-08-01 完美像素未知结果先对账再定性
- 缺陷:`snapImageToPerfectPixels` 的 catch 对所有错误一视同仁——标 `failed``finally` 解锁、按钮恢复可点。transport 异常、abort 和 120 秒客户端超时因此被谎报成明确失败,而服务端此时很可能已经完成 OSS PUT、asset object、project resource、账号素材和画布回填,只是响应没回来。用户按提示重试就再造一整份对象、资源与素材。这直接违反本功能自己立下的契约:「结果未知时先 GET 权威项目 / 素材快照,由用户显式决定是否再次执行」。客户端未配 `EDITOR_REQUEST_RETRY_OPTIONS`(禁自动重放)这半条一直是达标的。
- 判别依据:`ApiClientError` 只在拿到服务端 `Response` 时由 `buildApiClientError` 构造,transport 异常、`AbortError``TimeoutError` 在重试判定后原样抛出。因此 `error instanceof ApiClientError` 即「服务端明确响应过、结果已知」,其余一律按未知处理。已知结果不发对账 GET,避免每个 `400` 都多打一次权威读取。
- 决策:未知结果先 `loadEditorProject` 取权威快照,再按占位是否存活分流。占位已被 completion 消费掉说明这次其实成功,按快照收口并写入正常的 `perfect-pixel` 历史,不报错。占位仍在说明画布没收到结果,同步快照消除本地与服务端偏差但**不写历史**,文案明确告知结果未知且素材库可能已有派生图、要求用户先核对再决定是否重试——持久化是非事务的,OSS 对象与账号素材可能已落库而画布回填未完成。对账 GET 本身失败时给出「权威快照读取失败」的独立文案,不退回谎报。
- 未覆盖:刷新页面后停在 `generating` 的占位仍无自动收口。占位在 POST 前已由 `flushProjectPersistence` 落库,`hydrateCanvasGenerationDialog` 原样恢复 `generating`,而该链路不进 `external_generation_job`,任务侧栏轮询看不到它,inline POST 的 Promise 随旧页面销毁。需要在 hydration 后加对账,且要先给 dialog 增加「属于无 durable job 的 inline 链路」标记,改动面大于本次,单独立项。客户端 120 秒超时相对服务端 30 秒预算是四倍冗余,调小可让对账更早发生,未处理。
- 验证:新增三条用例分别覆盖「未知但实际成功→按快照收口并写历史」「未知且占位存活→只同步快照、标失败、文案要求先核对」「`ApiClientError` 已知失败→不发对账 GET、不动快照」。既有用例 `keeps a failed perfect-pixel placeholder` 原本用裸 `Error` 表达「服务端识别不到网格」,语义不准且会误入对账路径,改为 `ApiClientError`。测试 harness 新增 `dialog-error` 输出,否则对账文案不可观测。
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
File diff suppressed because one or more lines are too long
@@ -10,6 +10,7 @@ import {
import { useRef, useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiClientError } from '../../services/apiClient';
import type {
CanvasGenerationDialogState,
CanvasLayer,
@@ -34,6 +35,7 @@ const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn());
const renderCropExpandImageMock = vi.hoisted(() => vi.fn());
const removeImageBackgroundMock = vi.hoisted(() => vi.fn());
const snapImageToPerfectPixelsMock = vi.hoisted(() => vi.fn());
const loadEditorProjectMock = vi.hoisted(() => vi.fn());
const resolveEditorImageReferenceDataUrlMock = vi.hoisted(() => vi.fn());
vi.mock('../../services/image-editor/editorImageReference', async () => {
@@ -62,6 +64,7 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
generateEditorIconSpritesheet: generateEditorIconSpritesheetMock,
generateEditorImage: generateEditorImageMock,
generateEditorSoundEffect: generateEditorSoundEffectMock,
loadEditorProject: loadEditorProjectMock,
splitEditorIconSpritesheet: splitEditorIconSpritesheetMock,
};
});
@@ -231,6 +234,9 @@ function GenerationWorkflowHarness({
: '-'}
</span>
<span data-testid="dialog-prompt">{activeDialog?.prompt || '-'}</span>
<span data-testid="dialog-error">
{activeDialog?.errorMessage || '-'}
</span>
<span data-testid="dialog-image-options">
{activeDialog
? `${activeDialog.mode}:${activeDialog.imageModel ?? '-'}:${activeDialog.aspectRatio ?? '-'}:${activeDialog.imageSize ?? '-'}`
@@ -925,6 +931,9 @@ function GenerationWorkflowHarness({
describe('useImageCanvasGenerationWorkflow', () => {
beforeEach(() => {
vi.clearAllMocks();
// 中文注释:未知结果的对账 GET 默认返回 null,等价于"权威快照读不到"。需要具体
// 对账结果的用例各自 mockResolvedValueOnce 覆盖。
loadEditorProjectMock.mockResolvedValue(null);
resolveEditorImageReferenceDataUrlMock.mockImplementation(
async (src: string) => src,
);
@@ -2256,6 +2265,153 @@ describe('useImageCanvasGenerationWorkflow', () => {
});
});
it('reconciles an unknown perfect-pixel outcome that actually succeeded', async () => {
// 中文注释:120 秒超时或 transport abort 时服务端可能已经写完。此路由禁用自动
// 重放,直接标 failed 会谎报结果、诱使用户重试再造一份对象与素材。契约要求先
// GET 权威快照对账;占位已被 completion 消费掉即视为成功。
const applyProjectSnapshot = vi.fn();
const reconciledProject = {
projectId: 'project-1',
title: '未命名画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-08-01T00:00:00.000Z',
};
const timeoutError = new Error('The operation timed out.');
timeoutError.name = 'TimeoutError';
snapImageToPerfectPixelsMock.mockRejectedValueOnce(timeoutError);
loadEditorProjectMock.mockResolvedValueOnce(reconciledProject);
render(
<GenerationWorkflowHarness
projectId="project-1"
initialLayers={[
createLayer({
objectKey: 'generated-images/editor/source.png',
src: '/generated-images/editor/source.png',
}),
]}
applyProjectSnapshot={applyProjectSnapshot}
flushProjectPersistence={vi.fn(async () => {})}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '完美像素' }));
await waitFor(() => {
expect(loadEditorProjectMock).toHaveBeenCalledWith('project-1');
});
expect(applyProjectSnapshot).toHaveBeenCalledWith(reconciledProject, {
type: 'perfect-pixel',
count: 1,
});
await waitFor(() => {
expect(
screen.getByRole('status', { name: '完美像素状态' }).textContent,
).toBe('空闲');
});
expect(screen.getByTestId('dialog').textContent).not.toContain('failed');
expect(screen.getByTestId('dialog-error').textContent).toBe('-');
});
it('marks an unknown perfect-pixel outcome as unresolved when the placeholder survives', async () => {
// 中文注释:占位仍在权威快照里说明画布没收到结果。持久化是非事务的,OSS 对象与
// 账号素材仍可能已落库,因此只同步快照、不写历史,文案必须要求用户先核对。
const applyProjectSnapshot = vi.fn();
const reconciledProject = {
projectId: 'project-1',
title: '未命名画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
itemType: 'generation-dialog',
dialog: {
id: 'generation-dialog-1',
mode: 'quick-edit',
status: 'generating',
prompt: '完美像素',
},
},
],
resources: [],
updatedAt: '2026-08-01T00:00:00.000Z',
};
const abortError = new Error('The operation was aborted.');
abortError.name = 'AbortError';
snapImageToPerfectPixelsMock.mockRejectedValueOnce(abortError);
loadEditorProjectMock.mockResolvedValueOnce(reconciledProject);
render(
<GenerationWorkflowHarness
projectId="project-1"
initialLayers={[
createLayer({
objectKey: 'generated-images/editor/source.png',
src: '/generated-images/editor/source.png',
}),
]}
applyProjectSnapshot={applyProjectSnapshot}
flushProjectPersistence={vi.fn(async () => {})}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '完美像素' }));
await waitFor(() => {
expect(loadEditorProjectMock).toHaveBeenCalledWith('project-1');
});
// 中文注释:只同步快照,不带历史动作——这次没有成功。
expect(applyProjectSnapshot).toHaveBeenCalledWith(reconciledProject);
await waitFor(() => {
expect(screen.getByTestId('dialog').textContent).toContain('failed');
});
expect(screen.getByTestId('dialog-error').textContent).toContain(
'结果未知',
);
expect(screen.getByTestId('dialog-error').textContent).toContain(
'请先确认再决定是否重试',
);
});
it('keeps a rejected perfect-pixel request out of the reconciliation path', async () => {
// 中文注释:服务端明确响应过(ApiClientError)就是已知结果,不需要也不应该
// 再发对账 GET,否则每个 400 都要多打一次权威读取。
const applyProjectSnapshot = vi.fn();
snapImageToPerfectPixelsMock.mockRejectedValueOnce(
new ApiClientError({
message: 'assetKind 与来源素材权威类型不一致。',
status: 400,
code: 'HTTP_400',
}),
);
render(
<GenerationWorkflowHarness
projectId="project-1"
initialLayers={[
createLayer({
objectKey: 'generated-images/editor/source.png',
src: '/generated-images/editor/source.png',
}),
]}
applyProjectSnapshot={applyProjectSnapshot}
flushProjectPersistence={vi.fn(async () => {})}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '完美像素' }));
await waitFor(() => {
expect(screen.getByTestId('dialog').textContent).toContain('failed');
});
expect(loadEditorProjectMock).not.toHaveBeenCalled();
expect(applyProjectSnapshot).not.toHaveBeenCalled();
expect(screen.getByTestId('dialog-error').textContent).toBe(
'assetKind 与来源素材权威类型不一致。',
);
});
it('uploads an inline perfect-pixel source before flushing and posting', async () => {
const order: string[] = [];
uploadEditorMediaAssetFileMock.mockImplementationOnce(async () => {
@@ -2318,8 +2474,14 @@ describe('useImageCanvasGenerationWorkflow', () => {
it('keeps a failed perfect-pixel placeholder and releases busy state', async () => {
const applyProjectSnapshot = vi.fn();
// 中文注释:服务端明确拒绝(识别不到像素网格)是已知结果,用 ApiClientError 表达。
// 裸 Error 在生产里对应的是 transport 异常,属于未知结果,会走对账路径。
snapImageToPerfectPixelsMock.mockRejectedValueOnce(
new Error('像素网格无法识别'),
new ApiClientError({
message: '像素网格无法识别',
status: 422,
code: 'HTTP_422',
}),
);
render(
<GenerationWorkflowHarness
@@ -10,6 +10,7 @@ import {
useState,
} from 'react';
import { ApiClientError } from '../../services/apiClient';
import { resolveEditorImageReferenceDataUrl } from '../../services/image-editor/editorImageReference';
import { uploadEditorMediaAssetFile } from '../../services/image-editor/editorMediaAssetUploadClient';
import {
@@ -18,9 +19,11 @@ import {
type EditorProjectLayerSnapshot,
type EditorProjectResourceSnapshot,
type EditorProjectSnapshot,
loadEditorProject,
splitEditorIconSpritesheet,
} from '../../services/image-editor/editorProjectClient';
import { resizeCropExpandFrame } from './ImageCanvasCropExpandModel';
import { isCanvasGenerationDialogLayoutItem } from './ImageCanvasEditorModel';
import type {
CanvasGenerationDialogState,
CanvasHistoryAction,
@@ -1846,10 +1849,52 @@ export function useImageCanvasGenerationWorkflow({
setActiveTool('select');
setActiveSidebarPanel('layers');
} catch (error) {
// 中文注释:ApiClientError 只在拿到服务端 Response 时构造(apiClient 的
// buildApiClientError),transport 异常、abort 和 120 秒超时会原样抛出。
// 前者结果已知,后者未知——服务端可能已经完成 OSS PUT、asset object、
// project resource、账号素材和画布回填,只是响应没回来。该路由是 unsafe
// POST 且禁用自动重放,所以未知时必须先 GET 权威快照对账再决定文案:直接
// 标 failed 会谎报结果,用户重试就再造一份对象、资源和素材。
const outcomeIsUnknown = !(error instanceof ApiClientError);
let reconciledMessage: string | undefined;
if (outcomeIsUnknown && perfectPixelDialogId) {
const reconciled = await loadEditorProject(normalizedProjectId).catch(
() => null,
);
if (reconciled) {
const placeholderSurvived = reconciled.layers.some(
(item) =>
isCanvasGenerationDialogLayoutItem(item) &&
(item as { dialog?: { id?: unknown } }).dialog?.id ===
perfectPixelDialogId,
);
if (!placeholderSurvived) {
// 中文注释:占位已被服务端 completion 消费掉,说明这次其实成功了。
// 按权威快照收口并写入正常的完美像素历史,不再报错。
applyProjectSnapshot(reconciled, {
type: 'perfect-pixel',
count: 1,
});
setActiveTool('select');
setActiveSidebarPanel('layers');
return;
}
// 中文注释:占位仍在,画布没收到结果。先同步权威快照消除本地与服务端的
// 偏差,但不写历史——这次没有成功。持久化是非事务的,OSS 对象与账号素材
// 仍可能已经落库,所以文案必须让用户先去核对而不是直接重试。
applyProjectSnapshot(reconciled);
reconciledMessage =
'完美像素结果未知:已核对权威快照,画布未收到结果。素材库可能已存在派生图,请先确认再决定是否重试。';
} else {
reconciledMessage =
'完美像素结果未知,且权威快照读取失败。请刷新后确认素材库与画布,再决定是否重试。';
}
}
const errorMessage =
error instanceof Error && error.message.trim()
reconciledMessage ??
(error instanceof Error && error.message.trim()
? error.message
: '完美像素处理失败';
: '完美像素处理失败');
if (
perfectPixelDialogId &&
hasCanvasGenerationDialogById(perfectPixelDialogId)