修复 AGC 最近项目「检查失败」被钉死:失败退避重试 + 按轮重查
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m20s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 57s
Project CI / Backend tests (pull_request) Failing after 19s
Project CI / Frontend tests (pull_request) Successful in 2m3s
Project CI / Repository checks (pull_request) Failing after 14s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m20s
Project CI / Native shell tests (pull_request) Successful in 5m31s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 7m28s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m11s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m20s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 57s
Project CI / Backend tests (pull_request) Failing after 19s
Project CI / Frontend tests (pull_request) Successful in 2m3s
Project CI / Repository checks (pull_request) Failing after 14s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m20s
Project CI / Native shell tests (pull_request) Successful in 5m31s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 7m28s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m11s
- useRecentProjects:单次目录检查失败先就地重试一次(300ms),不再立刻落成失败状态 - useRecentProjects:增量投影只保留成功结果,上一轮的失败项回到「检查中」并重新检查 - useRecentProjects:一轮结束仍有失败行时按 15s/45s/120s 退避重跑整张列表,连续无失败即清零 - app/tauri:导出命名类型 TauriInvoke,替换 3 处 ReturnType<typeof resolveTauriInvoke> 写法 - tests/recentProjectsHook:新增「单次失败会重试」「刷新时重查失败项」两个用例 - tests/appSurface/home.suite:失败状态改为异步等待(重试后才会落成)
This commit is contained in:
@@ -1,3 +1,12 @@
|
||||
export function resolveTauriInvoke() {
|
||||
/**
|
||||
* AGC 调用 Rust 命令的唯一入口类型。全局 `window.__TAURI__.core.invoke` 由 Tauri 注入,
|
||||
* 消费方一律从这个名字取类型,不要再写 `ReturnType<typeof resolveTauriInvoke>`。
|
||||
*/
|
||||
export type TauriInvoke = <Result = unknown>(
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
) => Promise<Result>;
|
||||
|
||||
export function resolveTauriInvoke(): TauriInvoke | undefined {
|
||||
return window.__TAURI__?.core?.invoke;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { resolveTauriInvoke } from '../../app/tauri';
|
||||
import { resolveTauriInvoke, type TauriInvoke } from '../../app/tauri';
|
||||
import type { LocalProjectDirectoryStatus } from '../../app/types';
|
||||
import {
|
||||
isAbsoluteProjectPath,
|
||||
@@ -21,6 +21,17 @@ import {
|
||||
} from './model';
|
||||
|
||||
const RECENT_WORKSPACE_CHECK_TIMEOUT_MS = 5_000;
|
||||
/**
|
||||
* 单次目录检查失败后的就地重试退避。Tauri IPC 或磁盘的瞬时抖动不该把整行钉成
|
||||
* 「检查失败」;只有持续失败才落成失败状态。
|
||||
*/
|
||||
const RECENT_WORKSPACE_CHECK_RETRY_DELAYS_MS = [300];
|
||||
/**
|
||||
* 一轮检查结束仍有失败行时,按退避重跑整张列表(重跑次数上限 = 数组长度)。
|
||||
* 失败不进终态:例如 IPC 响应链路卡住十几秒之后,列表必须能自己恢复,
|
||||
* 而不是保持「检查失败」直到用户重启客户端。
|
||||
*/
|
||||
const RECENT_WORKSPACE_FAILURE_RECHECK_DELAYS_MS = [15_000, 45_000, 120_000];
|
||||
|
||||
export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
|
||||
const [recentWorkspaces, setRecentWorkspaces] =
|
||||
@@ -35,9 +46,11 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
|
||||
const recentWorkspacesRef = useRef(recentWorkspaces);
|
||||
recentWorkspacesRef.current = recentWorkspaces;
|
||||
const inspectionGenerationRef = useRef(0);
|
||||
const failureRecheckTimerRef = useRef<number | undefined>(undefined);
|
||||
const failureStreakRef = useRef(0);
|
||||
|
||||
async function inspectRecentWorkspace(
|
||||
invoke: NonNullable<ReturnType<typeof resolveTauriInvoke>>,
|
||||
invoke: TauriInvoke,
|
||||
workspace: string,
|
||||
): Promise<[string, LocalProjectDirectoryStatus | null]> {
|
||||
let timeoutHandle: number | undefined;
|
||||
@@ -63,6 +76,28 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一轮检查结束仍有失败行时,按退避重跑整张列表;连续无失败的一轮会把计数清零。
|
||||
* 这是「一次抖动不能把列表钉死」的兜底:即使无人操作,列表也会自己恢复。
|
||||
*/
|
||||
function scheduleFailureRecheck(failedCount: number) {
|
||||
window.clearTimeout(failureRecheckTimerRef.current);
|
||||
if (failedCount === 0) {
|
||||
failureStreakRef.current = 0;
|
||||
return;
|
||||
}
|
||||
const recheckDelayMs =
|
||||
RECENT_WORKSPACE_FAILURE_RECHECK_DELAYS_MS[failureStreakRef.current];
|
||||
if (recheckDelayMs === undefined) {
|
||||
return;
|
||||
}
|
||||
failureStreakRef.current += 1;
|
||||
failureRecheckTimerRef.current = window.setTimeout(() => {
|
||||
failureRecheckTimerRef.current = undefined;
|
||||
setRecentWorkspaceRefreshKey((current) => current + 1);
|
||||
}, recheckDelayMs);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke || recentWorkspaces.length === 0) {
|
||||
@@ -72,16 +107,34 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
|
||||
return;
|
||||
}
|
||||
const inspectionGeneration = ++inspectionGenerationRef.current;
|
||||
// 单次目录检查失败就地重试:瞬时抖动不该把整行钉成「检查失败」。
|
||||
const inspectRecentWorkspaceWithRetry = async (
|
||||
workspace: string,
|
||||
): Promise<[string, LocalProjectDirectoryStatus | null]> => {
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
const result = await inspectRecentWorkspace(invoke, workspace);
|
||||
const retryDelayMs = RECENT_WORKSPACE_CHECK_RETRY_DELAYS_MS[attempt];
|
||||
if (result[1] || retryDelayMs === undefined) {
|
||||
return result;
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
window.setTimeout(resolve, retryDelayMs);
|
||||
});
|
||||
}
|
||||
};
|
||||
let disposed = false;
|
||||
let pendingCount = recentWorkspaces.length;
|
||||
// 保留已完成项目的最后一个独立结果。刷新是增量投影:只有新项目或
|
||||
// 尚未完成检查的项目显示“检查中”,不能因为另一个坏目录而把整张列表
|
||||
// 清空成同一个异常状态。
|
||||
let failedCount = 0;
|
||||
// 刷新是增量投影:已完成项目的成功结果保留下来(不能因为另一个坏目录把整张
|
||||
// 列表清空成同一个异常状态),但上一轮的失败结果**不进新投影**——失败项回到
|
||||
// 「检查中」并重新检查。否则一次瞬时失败会把结果原样搬到下一轮,整行永久钉在
|
||||
// 「检查失败」上。
|
||||
setRecentWorkspaceStatuses((current) => {
|
||||
const next: Record<string, LocalProjectDirectoryStatus | null> = {};
|
||||
for (const workspace of recentWorkspaces) {
|
||||
if (Object.prototype.hasOwnProperty.call(current, workspace)) {
|
||||
next[workspace] = current[workspace] ?? null;
|
||||
const status = current[workspace];
|
||||
if (status) {
|
||||
next[workspace] = status;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
@@ -89,7 +142,7 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
|
||||
setRecentWorkspaceRefreshing(true);
|
||||
|
||||
for (const workspace of recentWorkspaces) {
|
||||
void inspectRecentWorkspace(invoke, workspace).then(
|
||||
void inspectRecentWorkspaceWithRetry(workspace).then(
|
||||
([projectPath, status]) => {
|
||||
if (
|
||||
disposed ||
|
||||
@@ -98,6 +151,9 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!status) {
|
||||
failedCount += 1;
|
||||
}
|
||||
setRecentWorkspaceStatuses((current) => ({
|
||||
...current,
|
||||
[projectPath]: status,
|
||||
@@ -105,12 +161,15 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
|
||||
pendingCount -= 1;
|
||||
if (pendingCount === 0) {
|
||||
setRecentWorkspaceRefreshing(false);
|
||||
scheduleFailureRecheck(failedCount);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.clearTimeout(failureRecheckTimerRef.current);
|
||||
failureRecheckTimerRef.current = undefined;
|
||||
};
|
||||
}, [recentWorkspaces, recentWorkspaceRefreshKey]);
|
||||
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ import {
|
||||
CONVERSATION_INITIAL_VISIBLE_COUNT,
|
||||
CONVERSATION_VISIBLE_STEP,
|
||||
} from '../../../../app/constants';
|
||||
import { resolveTauriInvoke } from '../../../../app/tauri';
|
||||
import { resolveTauriInvoke, type TauriInvoke } from '../../../../app/tauri';
|
||||
import type { ChatMessage, DirectTurnCancelView } from '../../../../app/types';
|
||||
import { projectRuntimeVisibleError } from '../../../../features/agent-runtime';
|
||||
import { uploadLocalFilesAsAttachments } from '../../../../features/app-shell/useHomeProjectCreation';
|
||||
@@ -556,7 +556,7 @@ export function useDirectProjectChatController({
|
||||
}
|
||||
|
||||
async function runTurn(
|
||||
invoke: NonNullable<ReturnType<typeof resolveTauriInvoke>>,
|
||||
invoke: TauriInvoke,
|
||||
nextProjectPath: string,
|
||||
input: DirectProjectTurnInput,
|
||||
) {
|
||||
|
||||
@@ -2915,7 +2915,8 @@ export function registerRecentProjectsTests() {
|
||||
expect(screen.getByText('不是文件夹')).not.toBeNull();
|
||||
expect(screen.getAllByText('未初始化').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('无法读取')).not.toBeNull();
|
||||
expect(screen.getByText('检查失败')).not.toBeNull();
|
||||
// 目录检查失败会先就地重试一次(失败不进终态),因此这里等它落成最终的失败状态。
|
||||
expect(await screen.findByText('检查失败')).not.toBeNull();
|
||||
expect(screen.getByText('厨房突围')).not.toBeNull();
|
||||
expect(screen.getByText('已完成 · 预览运行中')).not.toBeNull();
|
||||
expect(
|
||||
|
||||
@@ -91,3 +91,75 @@ test('刷新坏项目时保留其它项目已确认的正常状态', async () =>
|
||||
await pendingInspection;
|
||||
});
|
||||
});
|
||||
|
||||
test('单次目录检查失败会就地重试,不会把整行钉成「检查失败」', async () => {
|
||||
let attempts = 0;
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command !== 'inspect_local_project_directory') {
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
}
|
||||
attempts += 1;
|
||||
if (attempts === 1) {
|
||||
throw new Error('Tauri IPC 瞬时失败');
|
||||
}
|
||||
return READY_PROJECT;
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
window.localStorage.setItem(
|
||||
'genarrative-ai-game-creator.recent-workspaces.v1',
|
||||
JSON.stringify(['/tmp/ready-project']),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useRecentProjects(vi.fn()));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.projectRows[0]).toMatchObject({
|
||||
status: '本地项目',
|
||||
canOpen: true,
|
||||
});
|
||||
});
|
||||
expect(attempts).toBe(2);
|
||||
expect(result.current.projectRows[0]?.status).not.toBe('检查失败');
|
||||
});
|
||||
|
||||
test('刷新时重新检查上一轮失败的项目,不沿用失败结果', async () => {
|
||||
const inspectCounts: Record<string, number> = {};
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command !== 'inspect_local_project_directory') {
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
}
|
||||
const projectPath = String(args?.projectPath ?? '');
|
||||
inspectCounts[projectPath] = (inspectCounts[projectPath] ?? 0) + 1;
|
||||
if (projectPath === '/tmp/broken-project') {
|
||||
throw new Error('Tauri IPC 持续失败');
|
||||
}
|
||||
return { ...READY_PROJECT, projectPath };
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
window.localStorage.setItem(
|
||||
'genarrative-ai-game-creator.recent-workspaces.v1',
|
||||
JSON.stringify(['/tmp/broken-project']),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useRecentProjects(vi.fn()));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.projectRows[0]?.status).toBe('检查失败');
|
||||
});
|
||||
const attemptsAfterFirstRun = inspectCounts['/tmp/broken-project'] ?? 0;
|
||||
expect(attemptsAfterFirstRun).toBeGreaterThan(1);
|
||||
|
||||
act(() => {
|
||||
result.current.rememberRecentWorkspace('/tmp/ready-project');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(inspectCounts['/tmp/broken-project']).toBeGreaterThan(
|
||||
attemptsAfterFirstRun,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user