diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationQueue.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationQueue.ts index f2a3a6e46..594e498ea 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationQueue.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationQueue.ts @@ -16,6 +16,10 @@ export const RESOURCE_CANVAS_ASSET_GENERATION_POLL_INTERVAL_MILLIS = 2_000; */ export const RESOURCE_CANVAS_ASSET_GENERATION_MISSING_RECORD_POLL_LIMIT = 15; +/** 账本读回的形状不合法(`undefined` / 非数组)时的原因文案。 */ +export const RESOURCE_CANVAS_ASSET_GENERATION_MALFORMED_READ_ERROR = + '生成任务状态返回值不是数组'; + /** * 一次提交的终局。 * @@ -164,14 +168,31 @@ export function createResourceCanvasAssetGenerationQueue( } replaceFromRecord(task, started); let missingRecordPolls = 0; + let readFailed = false; + let lastReadError = RESOURCE_CANVAS_ASSET_GENERATION_MALFORMED_READ_ERROR; for (;;) { - const records = (await deps.invoke( - 'list_local_project_asset_generations', - { projectPath }, - )) as LocalProjectAssetGenerationTaskRecord[]; + let records: LocalProjectAssetGenerationTaskRecord[]; + try { + records = (await deps.invoke( + 'list_local_project_asset_generations', + { projectPath }, + )) as LocalProjectAssetGenerationTaskRecord[]; + } catch (error) { + // IPC 拒绝(未注册 / 权限拒绝 / 账本读坏):按「本轮读不到」处理,绝不把拒绝往上抛—— + // 派发循环是 `void (async …)()`,抛出去就是未处理的 Promise 拒绝。 + records = []; + readFailed = true; + lastReadError = errorMessage(error); + } + if (!Array.isArray(records)) { + records = []; + readFailed = true; + lastReadError = RESOURCE_CANVAS_ASSET_GENERATION_MALFORMED_READ_ERROR; + } const record = records.find((item) => item.taskId === task.taskId); if (record) { missingRecordPolls = 0; + readFailed = false; replaceFromRecord(task, record); if (isTerminalStatus(record.status)) { return { @@ -188,12 +209,16 @@ export function createResourceCanvasAssetGenerationQueue( missingRecordPolls >= RESOURCE_CANVAS_ASSET_GENERATION_MISSING_RECORD_POLL_LIMIT ) { + // 「读不到通道」与「读到数组但没有这条」是两件事,文案必须能区分:前者是账本不可用, + // 后者是记录被账本上限淘汰或项目被换掉。 return { taskId: task.taskId, projectId: task.projectId, status: 'failed', record: null, - error: '生成任务账本里已找不到这条任务,已停止等待', + error: readFailed + ? `生成任务状态读取失败,已停止等待:${lastReadError}` + : '生成任务账本里已找不到这条任务,已停止等待', }; } } @@ -238,10 +263,29 @@ export function createResourceCanvasAssetGenerationQueue( return; } deps.replaceTask({ ...next, dispatched: true }); - const settlement = await dispatch(next); + let settlement: ResourceCanvasAssetGenerationSettlement; + try { + settlement = await dispatch(next); + } catch (error) { + // `dispatch` 已经把可预期的失败(提交失败 / 账本读失败 / 记录丢失)收口成 + // settlement;这里是最后一道兜底:任何意外抛出都不能变成未处理的 Promise 拒绝, + // 也不能让这条任务永远停在「已派发但未终态」把后面的排队任务卡死。 + settlement = { + taskId: next.taskId, + projectId: next.projectId, + status: 'failed', + record: null, + error: errorMessage(error), + }; + } markSettled(settlement); settleWaiters(settlement); - await deps.onSettled(settlement); + try { + await deps.onSettled(settlement); + } catch { + // 宿主收尾(配对读清单 / 提示条)失败不改变任务终局,也不能打断队列: + // 任务本身的状态已经写进列表并通知了等待者。 + } } } finally { draining = false; diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts index f70cd6cec..19a8c5afe 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts @@ -192,20 +192,39 @@ export function restoreResourceCanvasAssetGenerationTask( * 两个方向都要覆盖:本地任务用后端记录刷新状态与阶段;后端有、本地没有的记录(重开项目、 * 或本地列表被清空)恢复成一条历史任务。后端不再返回的本地任务保持原样——记录被账本上限 * 淘汰不该让前端把一条已完成任务抹掉。 + * + * `records` **在函数入口归一化**:宿主从 IPC 拿到的不一定是数组(旧壳没有这条命令、权限拒绝、 + * 账本文件读坏、命令未注册都可能给出 `undefined` 或别的形状)。非数组一律按「没有后端记录」 + * 处理——抛出去会变成 effect 里的未处理 Promise 拒绝,把「任务列表暂不可用」升级成整块视图出错。 + * 数组里的垃圾条目(`null` / 数字 / 缺 `taskId`)同样逐条丢弃,不让一条坏记录带崩整次恢复。 */ export function applyLocalProjectAssetGenerationRecords( tasks: readonly ResourceCanvasAssetGenerationTask[], - records: readonly LocalProjectAssetGenerationTaskRecord[], + records: + | readonly LocalProjectAssetGenerationTaskRecord[] + | null + | undefined, ): ResourceCanvasAssetGenerationTask[] { + const safeRecords = Array.isArray(records) + ? records.filter( + (record): record is LocalProjectAssetGenerationTaskRecord => + typeof record === 'object' && + record !== null && + typeof (record as LocalProjectAssetGenerationTaskRecord).taskId === + 'string' && + (record as LocalProjectAssetGenerationTaskRecord).taskId.trim() + .length > 0, + ) + : []; const recordsByTaskId = new Map( - records.map((record) => [record.taskId, record]), + safeRecords.map((record) => [record.taskId, record]), ); const merged = tasks.map((task) => { const record = recordsByTaskId.get(task.taskId); return record ? mergeLocalProjectAssetGenerationRecord(task, record) : task; }); const knownTaskIds = new Set(tasks.map((task) => task.taskId)); - const restored = records + const restored = safeRecords .filter((record) => !knownTaskIds.has(record.taskId)) .map(restoreResourceCanvasAssetGenerationTask); if (restored.length === 0) { diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index d1eb175ac..b9f4f3f31 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -6989,6 +6989,10 @@ export default function ProjectDevelopmentView({ * * 账本落在项目内的 `.agent/runtime/asset-generation-tasks/`,所以历史任务(含上次运行中断 * 的那些)在这里回到列表;后端已经把没人推进的记录收口为失败,前端不假装它还在跑。 + * + * 读不到账本(旧壳没有这条命令 / 权限拒绝 / 文件读坏 / 返回了非数组)时**保留本地列表**并把 + * 提示落到既有提示条上:静默返回会让用户以为「历史生成任务都不见了」,而抛出去会变成这个 + * effect 的未处理 Promise 拒绝。 */ useEffect(() => { const invoke = window.__TAURI__?.core?.invoke; @@ -7000,13 +7004,26 @@ export default function ProjectDevelopmentView({ setResourceAssetGenerationTasksPanelOpen(false); resourceAssetGenerationPanelTaskIdRef.current = null; void (async () => { + const reportUnavailable = () => { + if (!cancelled) { + setResourceWorkbenchNotice( + '生成任务列表读取失败,暂时无法恢复历史任务', + ); + } + }; let records: LocalProjectAssetGenerationTaskRecord[]; try { - records = await invoke( + const response = await invoke( 'list_local_project_asset_generations', { projectPath }, ); + if (!Array.isArray(response)) { + reportUnavailable(); + return; + } + records = response; } catch { + reportUnavailable(); return; } if (cancelled) { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index f8e630dfe..3d422ea46 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -2164,6 +2164,12 @@ export function registerProjectWorkbenchFoundationTests() { updatedAt: 0, }; } + if (command === 'list_local_project_asset_generations') { + // 生成任务账本是只读的项目内文件,与「布局读时提示 / 写回」不是同一条链路; + // 这里显式登记成「空账本」,否则严格桩会把这条读判成 unexpected invoke、 + // 让读不到账本的提示混进本用例要断言的「没有任何提示」里。 + return []; + } // 没有归并、没有丢弃就不该写回:这里失败关闭,避免「悄悄写了一次」被漏掉。 throw new Error(`unexpected invoke ${command}`); }, @@ -10802,6 +10808,98 @@ export function registerProjectAgentStatusTests() { ).toBeGreaterThanOrEqual(manifestReadsBefore + 2); }, 30_000); + /** + * 账本读不到时的视图:`listBehavior` 决定这次读取是「返回非数组」还是「直接拒绝」。 + * + * 真实场景分别是「旧壳没有这条命令、返回 undefined」与「命令未注册 / 权限拒绝抛错」。 + */ + async function renderGenerationLedgerUnavailableView( + listBehavior: () => unknown, + ) { + const manifest = createGameCreationAppManifest( + 'workbench-asset-generation-tasks-unavailable', + '生成任务账本不可读项目', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return resourceGraphForInputs(args); + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: args?.expectedProjectId, + mode: args?.mode, + revision: 0, + positions: [], + updatedAt: 0, + }; + } + if (command === 'update_local_project_resource_canvas_layout') { + return { + status: 'updated', + layout: { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: args?.expectedProjectId, + mode: args?.mode, + revision: 1, + positions: args?.positions, + updatedAt: 1, + }, + }; + } + if (command === 'list_local_project_asset_generations') { + return listBehavior(); + } + if (command === 'get_local_game_project_revision') { + return { revision: 7 }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + + render( + React.createElement(ProjectDevelopmentView, { + projectName: manifest.name, + projectPath: '/tmp/workbench-asset-generation-tasks-unavailable', + manifest, + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + supervisor: React.createElement('div', null, '项目总控'), + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), + }), + ); + + await openResourceBookCategory('UI 交互'); + } + + it('survives an unreadable generation task ledger without dropping local state', async () => { + // 旧壳 / 命令未注册:返回 undefined 而不是数组。 + await renderGenerationLedgerUnavailableView(() => undefined); + + expect(screen.getByRole('button', { name: '生成任务' })).not.toBeNull(); + expect( + await screen.findByText('生成任务列表读取失败,暂时无法恢复历史任务'), + ).not.toBeNull(); + }, 20_000); + + it('reports a rejected generation task ledger read instead of failing silently', async () => { + await renderGenerationLedgerUnavailableView(() => { + throw new Error('unexpected invoke list_local_project_asset_generations'); + }); + + expect(screen.getByRole('button', { name: '生成任务' })).not.toBeNull(); + expect( + await screen.findByText('生成任务列表读取失败,暂时无法恢复历史任务'), + ).not.toBeNull(); + }, 20_000); + it('routes the audio column entries to the existing audio generation chain', async () => { const manifest = createGameCreationAppManifest( 'workbench-bottom-toolbar-audio', diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts index d6ad96643..0410d26e9 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts @@ -3,10 +3,12 @@ import { describe, expect, test, vi } from 'vitest'; import { createResourceCanvasAssetGenerationQueue, mergeResourceCanvasAssetGenerationTasksWithRecords, + RESOURCE_CANVAS_ASSET_GENERATION_MISSING_RECORD_POLL_LIMIT, type ResourceCanvasAssetGenerationQueueDeps, type ResourceCanvasAssetGenerationSettlement, } from '../src/features/resource-canvas/resourceCanvasAssetGenerationQueue'; import { + applyLocalProjectAssetGenerationRecords, createResourceCanvasAssetGenerationTask, nextResourceCanvasAssetGenerationDispatch, RESOURCE_CANVAS_ASSET_GENERATION_LOCAL_QUEUE_PHASE, @@ -399,3 +401,90 @@ describe('本地排队驱动器', () => { }); }); }); + +describe('非预期 IPC 形状 / 失败下的健壮性', () => { + test('账本读回 undefined / 非数组 / 垃圾条目时不抛错,本地任务列表保持原样', () => { + const tasks = [localTask('task-a', 10)]; + const unexpectedValues: unknown[] = [ + undefined, + null, + 'not-an-array', + 42, + { tasks: [] }, + [null, 7, 'x', {}, { taskId: '' }], + ]; + for (const unexpected of unexpectedValues) { + let merged: ResourceCanvasAssetGenerationTask[] = []; + expect(() => { + merged = applyLocalProjectAssetGenerationRecords( + tasks, + unexpected as readonly LocalProjectAssetGenerationTaskRecord[], + ); + }).not.toThrow(); + expect(merged).toHaveLength(1); + expect(merged[0]?.taskId).toBe('task-a'); + expect(merged[0]?.status).toBe('queued'); + } + }); + + test('轮询返回 undefined(旧壳 / 命令未注册)时不产生未处理拒绝,按上限收口为失败', async () => { + const harness = createHarness(); + harness.invoke.mockImplementation( + async (command: string, args?: Record) => { + if (command === 'start_local_project_asset_generation') { + return record(String(args?.taskId), 'running'); + } + if (command === 'list_local_project_asset_generations') { + // 旧壳 / 命令未注册:返回 undefined 而不是数组。 + return undefined; + } + throw new Error(`未预期的命令:${command}`); + }, + ); + const queue = createResourceCanvasAssetGenerationQueue(harness.deps); + const settled = queue.submit(localTask('task-a', 10)); + + await expect(settled).rejects.toThrow('生成任务状态读取失败'); + // 本地任务不被清空,只是按上限收口为失败并带上原因。 + const local = harness.tasks().find((item) => item.taskId === 'task-a'); + expect(local?.status).toBe('failed'); + expect(local?.phaseDetail).toContain('生成任务状态读取失败'); + expect(local?.phaseDetail).toContain('不是数组'); + expect(harness.settlements[0]).toMatchObject({ + taskId: 'task-a', + status: 'failed', + record: null, + }); + }); + + test('轮询 IPC 直接 reject 时同样被接住,收口为失败后继续放行排队任务', async () => { + const harness = createHarness(); + let listCalls = 0; + harness.invoke.mockImplementation( + async (command: string, args?: Record) => { + if (command === 'start_local_project_asset_generation') { + return record(String(args?.taskId), 'running'); + } + if (command === 'list_local_project_asset_generations') { + listCalls += 1; + if ( + listCalls <= RESOURCE_CANVAS_ASSET_GENERATION_MISSING_RECORD_POLL_LIMIT + ) { + throw new Error('项目权限策略拒绝执行:asset.read'); + } + return [record('task-b', 'completed', { assetId: 'asset-task-b' })]; + } + throw new Error(`未预期的命令:${command}`); + }, + ); + const queue = createResourceCanvasAssetGenerationQueue(harness.deps); + const first = queue.submit(localTask('task-a', 10)); + const second = queue.submit(localTask('task-b', 20)); + + await expect(first).rejects.toThrow( + '生成任务状态读取失败,已停止等待:项目权限策略拒绝执行:asset.read', + ); + await expect(second).resolves.toBeUndefined(); + expect(harness.startCallCount()).toBe(2); + }); +});