fix(agc): 生成任务账本读不到时不再抛未处理拒绝,改为保留本地状态并给出提示
- resourceCanvasAssetGenerationTaskModel.applyLocalProjectAssetGenerationRecords:入参在函数入口归一化, 非数组(undefined / null / 字符串 / 数字 / 对象)一律按「没有后端记录」处理,数组内的垃圾条目 (null / 数字 / 缺 taskId)逐条丢弃;类型放宽为 `readonly …[] | null | undefined` - resourceCanvasAssetGenerationQueue:轮询 `list_local_project_asset_generations` 的 IPC 拒绝与非数组 返回值都在本地接住,按「本轮读不到」计数;到上限后按「账本不可用」与「账本里没有这条」两种原因分别收口; 派发循环再补两道兜底(dispatch 意外抛出、宿主 onSettled 抛出),保证 `void (async …)()` 不产生未处理拒绝、 也不会把任务永远卡在「已派发但未终态」 - index.tsx 恢复任务账本的 effect:IPC 拒绝与返回非数组都落到既有提示条 「生成任务列表读取失败,暂时无法恢复历史任务」,本地任务列表保持原样不清空 - 测试:模型层补「undefined / 非数组 / 垃圾条目不抛错且列表不变」,驱动器层补 「轮询返回 undefined」「轮询 IPC reject」两条(收口原因、不产生未处理拒绝、后续排队任务照常补发) - AppSurface 补两条:账本返回 undefined / 读取被拒绝时视图照常渲染、生成入口还在、提示可见 - AppSurface 既有的「布局读时提示」用例显式登记 `list_local_project_asset_generations` 返回空账本: 该命令是只读的独立链路,严格桩把它判成 unexpected invoke 会让新提示混进「没有任何提示」的断言
This commit is contained in:
+51
-7
@@ -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;
|
||||
|
||||
+22
-3
@@ -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) {
|
||||
|
||||
@@ -6854,6 +6854,10 @@ export default function ProjectDevelopmentView({
|
||||
*
|
||||
* 账本落在项目内的 `.agent/runtime/asset-generation-tasks/`,所以历史任务(含上次运行中断
|
||||
* 的那些)在这里回到列表;后端已经把没人推进的记录收口为失败,前端不假装它还在跑。
|
||||
*
|
||||
* 读不到账本(旧壳没有这条命令 / 权限拒绝 / 文件读坏 / 返回了非数组)时**保留本地列表**并把
|
||||
* 提示落到既有提示条上:静默返回会让用户以为「历史生成任务都不见了」,而抛出去会变成这个
|
||||
* effect 的未处理 Promise 拒绝。
|
||||
*/
|
||||
useEffect(() => {
|
||||
const invoke = window.__TAURI__?.core?.invoke;
|
||||
@@ -6865,13 +6869,26 @@ export default function ProjectDevelopmentView({
|
||||
setResourceAssetGenerationTasksPanelOpen(false);
|
||||
resourceAssetGenerationPanelTaskIdRef.current = null;
|
||||
void (async () => {
|
||||
const reportUnavailable = () => {
|
||||
if (!cancelled) {
|
||||
setResourceWorkbenchNotice(
|
||||
'生成任务列表读取失败,暂时无法恢复历史任务',
|
||||
);
|
||||
}
|
||||
};
|
||||
let records: LocalProjectAssetGenerationTaskRecord[];
|
||||
try {
|
||||
records = await invoke<LocalProjectAssetGenerationTaskRecord[]>(
|
||||
const response = await invoke<LocalProjectAssetGenerationTaskRecord[]>(
|
||||
'list_local_project_asset_generations',
|
||||
{ projectPath },
|
||||
);
|
||||
if (!Array.isArray(response)) {
|
||||
reportUnavailable();
|
||||
return;
|
||||
}
|
||||
records = response;
|
||||
} catch {
|
||||
reportUnavailable();
|
||||
return;
|
||||
}
|
||||
if (cancelled) {
|
||||
|
||||
@@ -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}`);
|
||||
},
|
||||
@@ -10800,6 +10806,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<string, unknown>) => {
|
||||
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',
|
||||
|
||||
@@ -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<string, unknown>) => {
|
||||
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<string, unknown>) => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user