From dfebcfb66a5cd859c2693903a230d804722e405b Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 27 Aug 2026 13:40:57 +0800 Subject: [PATCH 1/4] fix(workflow-controller): allow failed direction recovery A direction retry could lock sibling failures once the node returned to generating. Permit retries during generation only when the referenced task snapshot is failed. Users can continue recovering the same action without replacing completed directions. --- .../workflow-controller/controller.ts | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts index 5e3f2fc0..ed8cd29a 100644 --- a/frontend/src/features/workflow-controller/controller.ts +++ b/frontend/src/features/workflow-controller/controller.ts @@ -1287,13 +1287,27 @@ export function createWorkflowController({ const originalNode = structuredClone(findNode(before, nodeId)) const role = generationRoleForNode(originalNode) if (!role) throw new Error('目标节点不是生成节点') - if (originalNode.status !== 'failed' && originalNode.phase !== 'selecting') { - throw new Error('当前方向不能重新生成') - } const reference = originalNode.generations.find( (item) => item.role === role && generationReferenceDirection(item) === direction, ) if (!reference) throw new Error(`方向 ${direction} 没有可替换的生成任务`) + if (originalNode.status !== 'failed' && originalNode.phase !== 'selecting') { + if (originalNode.status !== 'active' || originalNode.phase !== 'generating') { + throw new Error('当前方向不能重新生成') + } + const expectation = generationExpectationForNode( + before, + originalNode, + reference.direction, + reference.role, + ) + const generation = expectation + ? await generationApis.get(before.projectId, reference.taskId, expectation) + : null + if (generation?.status !== 'failed') { + throw new Error('当前方向不能重新生成') + } + } if (originalNode.type !== 'action-full-frame') { ensurePositiveInteger(options.spriteWidth, 'spriteWidth') ensurePositiveInteger(options.spriteHeight, 'spriteHeight') From 14ea604818c1534c18e824224c24ef8cc4ce427b Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 27 Aug 2026 13:41:06 +0800 Subject: [PATCH 2/4] test(workflow-controller): cover sibling direction retries The controller regression needs to preserve retries after one direction starts generating. Exercise two failed sibling snapshots while the node remains active and generating. The test prevents direction recovery from returning to the previous state lock. --- .../workflow-controller/controller.test.ts | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/frontend/src/features/workflow-controller/controller.test.ts b/frontend/src/features/workflow-controller/controller.test.ts index 22b3ff26..3893316b 100644 --- a/frontend/src/features/workflow-controller/controller.test.ts +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -2171,6 +2171,78 @@ describe('WorkflowController', () => { ]) }) + it('一个方向进入重试后仍允许补跑同节点的其它失败方向', async () => { + const run = createRun([ + setupNode({ + status: 'passed', + phase: 'completed', + input: { prompt: '像素骑士', referenceMedia: [], characterId: 'character-1' }, + }), + templateNode({ status: 'passed', phase: 'completed', selectedImageUrl: 'east-template.png' }), + firstFrameNode({ + status: 'passed', + phase: 'completed', + selectedFirstFrameUrl: 'east-frame.png', + selectedFirstFrameUrls: { + east: 'east-frame.png', + north: 'north-frame.png', + south: 'south-frame.png', + }, + }), + generationMethodNode({ status: 'passed', phase: 'completed', method: 'video-cropping' }), + fullFrameNode({ + status: 'active', + phase: 'generating', + input: { prompt: '向前挥拳、击中后自然收势' }, + generations: [ + { taskId: 'retry-east', role: 'complete_animation' }, + { taskId: 'failed-north', role: 'complete_animation', direction: 'north' }, + { taskId: 'failed-south', role: 'complete_animation', direction: 'south' }, + ], + error: null, + }), + reviewNode(), + ]) + const { controller, generation } = createController(run, 'four-way') + generation.snapshots.set('retry-east', { + id: 'retry-east', + projectId: '1', + type: 'complete_animation', + status: 'pending', + result: null, + error: null, + }) + for (const direction of ['north', 'south'] as const) { + generation.snapshots.set(`failed-${direction}`, { + id: `failed-${direction}`, + projectId: '1', + type: 'complete_animation', + status: 'failed', + result: null, + error: `${direction} provider failed`, + }) + } + + await controller.retryGenerationDirection('action-walk:action-full-frame', 'north', { + spriteWidth: 64, + spriteHeight: 64, + }) + await controller.retryGenerationDirection('action-walk:action-full-frame', 'south', { + spriteWidth: 64, + spriteHeight: 64, + }) + + expect(generation.apis.create).toHaveBeenCalledTimes(2) + expect(generation.apis.create).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ direction: 'north' }), + ) + expect(generation.apis.create).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ direction: 'south' }), + ) + }) + it('重试东向时只清空对应的兼容选择字段', async () => { const templateRetry = createController( createRun([ From 65ea5c61e1c08f0ba7705b27f4897c017b7bfa5e Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 27 Aug 2026 13:41:14 +0800 Subject: [PATCH 3/4] fix(quick-start): retry all failed action directions Separate retry buttons forced users to coordinate internal direction state manually. Group failures by node, retry them sequentially, and preserve the latest successful run. One recovery action now keeps completed directions and reports any remaining failure. --- frontend/src/pages/quick-start/index.tsx | 67 +++++++++++++----------- 1 file changed, 36 insertions(+), 31 deletions(-) diff --git a/frontend/src/pages/quick-start/index.tsx b/frontend/src/pages/quick-start/index.tsx index 717791fa..a40d76c3 100644 --- a/frontend/src/pages/quick-start/index.tsx +++ b/frontend/src/pages/quick-start/index.tsx @@ -2726,7 +2726,7 @@ function QuickStartRun({ readonly (readonly [string, string])[] >([]) const [failedDirections, setFailedDirections] = useState([]) - const [retryingDirection, setRetryingDirection] = useState(null) + const [retryingDirectionsForNode, setRetryingDirectionsForNode] = useState(null) const [exportModel, setExportModel] = useState(null) const [publishing, setPublishing] = useState(false) const [confirmingCandidate, setConfirmingCandidate] = useState(false) @@ -3439,22 +3439,34 @@ function QuickStartRun({ } } - async function retryFailedDirection(item: QuickStartFailedDirection) { + async function retryFailedDirections(items: readonly QuickStartFailedDirection[]) { const targetSession = session - if (!targetSession || workflowConflictRef.current) return - const key = `${item.nodeId}:${item.direction}` - setRetryingDirection(key) + const nodeId = items[0]?.nodeId + if (!targetSession || !nodeId || workflowConflictRef.current) return + setRetryingDirectionsForNode(nodeId) clearWorkflowError() + let latestRun: WorkflowRun | null = null + let firstFailure: unknown = null try { - const updated = await targetSession.retryGenerationDirection(item.nodeId, item.direction) - if (!mountedRef.current || activeSessionRef.current !== targetSession) return - setRun(updated) - } catch (cause) { + for (const item of items) { + try { + latestRun = await targetSession.retryGenerationDirection(item.nodeId, item.direction) + } catch (cause) { + firstFailure ??= cause + } + } if (!mountedRef.current || activeSessionRef.current !== targetSession) return - reportWorkflowError(cause, `重试${DIRECTION_LABELS[item.direction]}方向失败`) + if (latestRun) setRun(latestRun) + if (firstFailure) { + const fallback = + items.length === 1 + ? `重试${DIRECTION_LABELS[items[0]!.direction]}方向失败` + : '重试失败方向时仍有任务未能恢复' + reportWorkflowError(firstFailure, fallback) + } } finally { if (mountedRef.current && activeSessionRef.current === targetSession) { - setRetryingDirection(null) + setRetryingDirectionsForNode(null) } } } @@ -3462,25 +3474,18 @@ function QuickStartRun({ function DirectionRetryButtons({ nodeId }: { nodeId: string }) { const items = failedDirections.filter((item) => item.nodeId === nodeId) if (items.length === 0) return null + const retrying = retryingDirectionsForNode === nodeId + const label = + items.length === 1 ? `重试${DIRECTION_LABELS[items[0]!.direction]}方向` : '重试失败方向' return ( -
- {items.map((item) => { - const key = `${item.nodeId}:${item.direction}` - return ( - - ) - })} -
+ ) } @@ -3949,7 +3954,7 @@ function QuickStartRun({ <> @@ -4065,7 +4070,7 @@ function QuickStartRun({ <> From 0d71f13c288e77e75d857d2a06a1f4ef0a2ba921 Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 27 Aug 2026 13:41:23 +0800 Subject: [PATCH 4/4] test(quick-start): cover grouped failed direction recovery Quick Start needs a regression for the single-action recovery experience. Verify sequential retries, the shared busy label, and the preserved-results guidance. The test guards the user-facing path that previously required manual retry ordering. --- frontend/src/pages/quick-start/index.test.tsx | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/frontend/src/pages/quick-start/index.test.tsx b/frontend/src/pages/quick-start/index.test.tsx index 45cb31ea..5824fd2c 100644 --- a/frontend/src/pages/quick-start/index.test.tsx +++ b/frontend/src/pages/quick-start/index.test.tsx @@ -3864,6 +3864,37 @@ describe('QuickStartPage', () => { ) }) + it('一次补跑同一节点当前所有失败方向并保留进行中反馈', async () => { + const run = actionWorkflow({ fullStatus: 'failed', error: '多个方向失败' }) + const firstRetry = deferred() + const service = serviceFor(run, { + getFailedGenerationDirections: vi.fn(async () => [ + { nodeId: 'action-full', direction: 'east' as const }, + { nodeId: 'action-full', direction: 'north' as const }, + { nodeId: 'action-full', direction: 'south' as const }, + ]), + retryGenerationDirection: vi + .fn() + .mockImplementationOnce(() => firstRetry.promise) + .mockResolvedValue(run), + }) + renderAt('/quick-start/run-1', service) + + expect( + await screen.findByLabelText('动作生成失败 已完成的方向会保留,点击下方可重试失败方向。'), + ).toBeTruthy() + const retry = await screen.findByRole('button', { name: '重试失败方向' }) + fireEvent.click(retry) + + const retrying = await screen.findByRole('button', { name: '正在重试失败方向…' }) + expect((retrying as HTMLButtonElement).disabled).toBe(true) + firstRetry.resolve(run) + await waitFor(() => expect(service.retryGenerationDirection).toHaveBeenCalledTimes(3)) + expect(service.retryGenerationDirection).toHaveBeenNthCalledWith(1, 'action-full', 'east') + expect(service.retryGenerationDirection).toHaveBeenNthCalledWith(2, 'action-full', 'north') + expect(service.retryGenerationDirection).toHaveBeenNthCalledWith(3, 'action-full', 'south') + }) + it('角色母版失败时也提供定向重试入口', async () => { const run = workflow( setupAndTemplate({