完美像素未知结果先对账再定性
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user