对账窗口改在 pre-POST flush 之后锚定

查服务端确认了一件推翻先前定性的事实:validate_editor_pixel_art_snap_
placeholder_exists 在处理前检查占位是否已经持久化,既没有同 ID 的已持久化
dialog、也没有同 operation 的稳定 resource 时直接返回 409。所以 POST 前那次
await flush 不是可省的画布同步,而是服务端硬前置,不能取消。

据此更正上一条决策里的错误声明:不是「布局保存失败只降级成 asset-only」,
而是「占位从未持久化时服务端返回 409;best-effort flush 不再提供成功 ACK,
客户端因此无法证明该前置已满足,只能提高满足它的概率」。被解除的是客户端侧
「拿不到 revision ack 就拒发」的阻断,不是端到端依赖。

缺陷:首次提交与人工 exact retry 都在这次 flush 之前就算好 submittedAt /
reconcileUntil。该 flush 没有整体上限,慢保存足以在 POST 发出前烧光整个
75 秒窗口,请求带着已过期的 deadline 发出,对账退化成读一次就收尾。

修复:flush 返回、authority 复核通过之后才用
createPerfectPixelReconciliationOperation 重新锚定,按同一 operationId
覆盖账本与 dialog,登记新的 recovery key,随后立即 POST。只覆盖时间字段,
request 与 dialog / operation / task identity 逐字节不变,不产生第二条账本。

保留 flush 前的预写而不是整体后移:flush 期间另一标签页可能加载同一项目,
服务端已有带 marker 的占位而 localStorage 跨标签共享,本机若没有账本那条占位
会被 hydrate 成 failed + invalid。provisional 账本正好堵住这个窗口。

同步修正专题文档中已被近几个提交推翻的条款:strict layout save 预算与
revision ACK 前 POST 为零、未收口 operation 不可删除、durable operation
不得被普通删除路径清理。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 08:43:59 +00:00
parent 515682d571
commit 4515bbcff6
4 changed files with 245 additions and 23 deletions
@@ -2917,6 +2917,173 @@ describe('useImageCanvasGenerationWorkflow', () => {
});
});
it('anchors the first submission reconciliation window at POST time when the pre-POST flush is slow', async () => {
// 中文注释:pre-POST flush 是服务端硬前置(占位未持久化会被 409 拒收),且没有整体
// 上限。窗口若锚在 flush 之前,慢保存会让 POST 带着已过期的 reconciliation deadline
// 发出。这里把时钟停住手工推进,断言 POST 那一刻窗口是刚建立的完整 75 秒。
let clock = 1_800_000_000_000;
const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock);
try {
const flushGate = createDeferred<void>();
const flushProjectPersistence = vi.fn(() => flushGate.promise);
let requestAtPost: EditorPixelArtSnapInput | undefined;
let ledgerAtPost: ReturnType<typeof readPerfectPixelOperations> =
new Map();
let clockAtPost = 0;
snapImageToPerfectPixelsMock.mockImplementationOnce(
async (request: EditorPixelArtSnapInput) => {
requestAtPost = request;
clockAtPost = clock;
ledgerAtPost = readPerfectPixelOperations('user-a', 'project-1');
throw new ApiClientError({
message: '素材类型校验失败。',
status: 400,
code: 'HTTP_400',
});
},
);
render(
<GenerationWorkflowHarness
projectId="project-1"
currentUserId="user-a"
applyProjectSnapshot={vi.fn()}
flushProjectPersistence={flushProjectPersistence}
initialLayers={[
createLayer({
objectKey: 'generated-images/editor/source.png',
src: '/generated-images/editor/source.png',
}),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '完美像素' }));
await waitFor(() => {
expect(flushProjectPersistence).toHaveBeenCalledTimes(1);
});
// 中文注释:flush 期间必须已有 provisional 账本——否则另一标签页加载同一项目时,
// 服务端那条带 marker 的占位会被 hydrate 成 failed + invalid。
const provisional = readPerfectPixelOperations('user-a', 'project-1');
expect(provisional.size).toBe(1);
const [provisionalOperation] = [...provisional.values()];
clock += 90_000;
flushGate.resolve();
await waitFor(() => {
expect(snapImageToPerfectPixelsMock).toHaveBeenCalledTimes(1);
});
const submitted = ledgerAtPost.get(provisionalOperation!.operationId);
expect(ledgerAtPost.size).toBe(1);
expect(submitted?.submittedAt).toBe(clockAtPost);
expect(submitted!.reconcileUntil - submitted!.submittedAt).toBe(75_000);
// 中文注释:只覆盖时间字段——identity 与请求体逐字节不变,也不产生第二条账本。
expect(submitted?.taskId).toBe(provisionalOperation!.taskId);
expect(JSON.stringify(submitted!.request)).toBe(
JSON.stringify(provisionalOperation!.request),
);
expect(JSON.stringify(requestAtPost)).toBe(
JSON.stringify(provisionalOperation!.request),
);
} finally {
nowSpy.mockRestore();
}
});
it('anchors the exact retry reconciliation window at POST time when the pre-POST flush is slow', async () => {
let clock = 1_800_000_000_000;
const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock);
try {
const retryFlushGate = createDeferred<void>();
const flushProjectPersistence = vi
.fn()
.mockResolvedValueOnce(undefined)
.mockImplementationOnce(() => retryFlushGate.promise);
let requestAtRetryPost: EditorPixelArtSnapInput | undefined;
let ledgerAtRetryPost: ReturnType<typeof readPerfectPixelOperations> =
new Map();
let clockAtRetryPost = 0;
snapImageToPerfectPixelsMock
.mockImplementationOnce(async (request: EditorPixelArtSnapInput) =>
createMismatchedPerfectPixelResult(request),
)
.mockImplementationOnce(async (request: EditorPixelArtSnapInput) => {
requestAtRetryPost = request;
clockAtRetryPost = clock;
ledgerAtRetryPost = readPerfectPixelOperations('user-a', 'project-1');
throw new ApiClientError({
message: '素材类型校验失败。',
status: 400,
code: 'HTTP_400',
});
});
loadEditorProjectMock.mockImplementation(async () => {
const request = snapImageToPerfectPixelsMock.mock.calls.at(-1)?.[0] as
| EditorPixelArtSnapInput
| undefined;
return createConflictingPerfectPixelProject(
request!.canvasCompletion.dialogId,
);
});
render(
<GenerationWorkflowHarness
projectId="project-1"
currentUserId="user-a"
applyProjectSnapshot={vi.fn()}
flushProjectPersistence={flushProjectPersistence}
initialLayers={[
createLayer({
objectKey: 'generated-images/editor/source.png',
src: '/generated-images/editor/source.png',
}),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '完美像素' }));
await waitFor(() => {
expect(screen.getByTestId('dialog').textContent).toContain(
'pending-confirmation',
);
});
const beforeRetry = JSON.parse(
screen.getByTestId('perfect-pixel-operation').textContent!,
) as { operationId: string; taskId: string; submittedAt: number };
fireEvent.click(screen.getByRole('button', { name: '重试完美像素' }));
await waitFor(() => {
expect(flushProjectPersistence).toHaveBeenCalledTimes(2);
});
// 中文注释:flush 期间不得提前刷新窗口——挂着的仍是旧 operation。
const duringFlush = JSON.parse(
screen.getByTestId('perfect-pixel-operation').textContent!,
) as { submittedAt: number };
expect(duringFlush.submittedAt).toBe(beforeRetry.submittedAt);
clock += 90_000;
retryFlushGate.resolve();
await waitFor(() => {
expect(snapImageToPerfectPixelsMock).toHaveBeenCalledTimes(2);
});
const retried = ledgerAtRetryPost.get(beforeRetry.operationId);
expect(ledgerAtRetryPost.size).toBe(1);
expect(retried?.submittedAt).toBe(clockAtRetryPost);
expect(retried!.reconcileUntil - retried!.submittedAt).toBe(75_000);
expect(retried?.taskId).toBe(beforeRetry.taskId);
const firstRequest = snapImageToPerfectPixelsMock.mock.calls[0]?.[0] as
| EditorPixelArtSnapInput
| undefined;
expect(JSON.stringify(requestAtRetryPost)).toBe(
JSON.stringify(firstRequest),
);
} finally {
nowSpy.mockRestore();
}
});
it('reuses one inline upload and the exact persisted POST bytes when an unknown operation is retried', async () => {
const flushProjectPersistence = vi.fn().mockResolvedValue(undefined);
uploadEditorMediaAssetObjectFileMock.mockResolvedValueOnce({
@@ -2394,14 +2394,45 @@ export function useImageCanvasGenerationWorkflow({
normalizedProjectId,
perfectPixelOperation,
);
// 中文注释:布局保存尽力而为——占位存进服务端后,服务端才能用 canvasCompletion
// 就地替换它。保存失败不拦 POST,只是把结果降级成「只进素材库」,由对账提示用户。
// 中文注释:这次 flush 不是可省的画布同步,而是服务端的硬前置——POST 到达时若占位
// 从未持久化,`validate_editor_pixel_art_snap_placeholder_exists` 直接 409。改成
// best-effort 之后客户端不再拿到成功 ACK,因此**无法证明**该前置已满足,只能提高
// 满足它的概率:占位可能已由此前的自动保存落库,PATCH 也可能成功而 ACK 丢失。
await flushProjectPersistence({
preferLatestGenerationDialogs: true,
}).catch(() => undefined);
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {
return;
}
// 中文注释:对账窗口必须锚在 POST 发出的时刻。上面这次 flush 没有整体上限(单次
// PATCH 60 秒 × 最多 4 次尝试),慢保存足以把 75 秒窗口在 POST 之前烧光,让请求
// 带着已过期的 deadline 发出。这里重新锚定——只覆盖时间字段,request、dialog /
// operation / task identity 与预写的那条完全一致,账本按同一 operationId 覆盖,
// 不会产生第二条。
//
// 预写仍然保留在 flush 之前:flush 期间另一标签页可能加载同一项目,此时服务端已有
// 带 marker 的占位,本机若没有账本就会被 hydrate 成 `failed + invalid`。预写的
// provisional 账本正好堵住这个窗口。
const submittedOperation = createPerfectPixelReconciliationOperation(
perfectPixelOperation,
);
perfectPixelOperation = submittedOperation;
observedPerfectPixelRecoveryKeysRef.current.add(
perfectPixelRecoveryKey(
currentUserId,
normalizedProjectId,
submittedOperation,
),
);
savePerfectPixelOperation(
currentUserId,
normalizedProjectId,
submittedOperation,
);
updateCanvasGenerationDialogById(perfectPixelDialogId, (dialog) => ({
...dialog,
perfectPixelOperation: submittedOperation,
}));
perfectPixelPostAttempted = true;
const result = await snapImageToPerfectPixels(
perfectPixelOperation.request,
@@ -2607,8 +2638,11 @@ export function useImageCanvasGenerationWorkflow({
if (perfectPixelLayerIdsRef.current.has(lockKey)) {
return;
}
const retriedOperation =
createPerfectPixelReconciliationOperation(operation);
// 中文注释:flush 之前继续沿用旧 operation——窗口只能锚在 POST 发出的时刻,而这次
// flush 是服务端硬前置且没有整体上限,先刷新窗口再等它等于把窗口烧在等待上。
// request 与 dialog / operation / task identity 前后完全一致,只有时间字段会在
// flush 之后被重新锚定。
let retriedOperation = operation;
let postAttempted = false;
perfectPixelLayerIdsRef.current.add(lockKey);
claimActiveInlineGenerationDialog(normalizedDialogId);
@@ -2620,6 +2654,24 @@ export function useImageCanvasGenerationWorkflow({
return nextLayerIds;
});
}
// 中文注释:UI 先切到处理中,让用户立刻看到重试已经开始;此时挂的仍是旧
// operation,账本里已有的那条与它一致,不需要重写。
updateCanvasGenerationDialogById(normalizedDialogId, (current) => ({
...current,
status: 'generating',
errorMessage: undefined,
requiresLiveSession: undefined,
perfectPixelOperationId: normalizedDialogId,
perfectPixelOperation: operation,
}));
await flushProjectPersistence({
preferLatestGenerationDialogs: true,
}).catch(() => undefined);
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {
return;
}
// 中文注释:flush 之后才重新锚定对账窗口,并按同一 operationId 覆盖账本与 dialog。
retriedOperation = createPerfectPixelReconciliationOperation(operation);
observedPerfectPixelRecoveryKeysRef.current.add(
perfectPixelRecoveryKey(
currentUserId,
@@ -2627,25 +2679,15 @@ export function useImageCanvasGenerationWorkflow({
retriedOperation,
),
);
updateCanvasGenerationDialogById(normalizedDialogId, (current) => ({
...current,
status: 'generating',
errorMessage: undefined,
requiresLiveSession: undefined,
perfectPixelOperationId: normalizedDialogId,
perfectPixelOperation: retriedOperation,
}));
savePerfectPixelOperation(
currentUserId,
normalizedProjectId,
retriedOperation,
);
await flushProjectPersistence({
preferLatestGenerationDialogs: true,
}).catch(() => undefined);
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {
return;
}
updateCanvasGenerationDialogById(normalizedDialogId, (current) => ({
...current,
perfectPixelOperation: retriedOperation,
}));
postAttempted = true;
const result = await snapImageToPerfectPixels(retriedOperation.request);
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {