评审收口:提权类失败不再重试、重命名路径复用重试、退避预算绑定失败集合
- useRecentProjects:区分可重试失败与提权/权限类失败,后者不再就地重试、也不驱动整表重查,避免自己驱动 UAC 反复弹窗 - useRecentProjects:提权类失败的项目在用户主动打开/新建项目或重命名刷新前跳过检查 - useRecentProjects:退避重查预算绑定到失败集合,集合变化或整轮无失败即重置,长期坏目录不再吃满额度 - useRecentProjects:重命名后的单条刷新复用同一套就地重试与有界重查 - app/tauri:删除重复的 TauriInvoke 类型,统一引用 app/types 的既有定义 - tests/recentProjectsHook:失败不跨轮保留改为断言在途「检查中」,新增提权类失败不重试用例 - docs:decision-log、pitfalls、生命周期方案与异步闭环方案同步失败自愈口径与提权边界
This commit is contained in:
@@ -1,12 +1,6 @@
|
||||
/**
|
||||
* AGC 调用 Rust 命令的唯一入口类型。全局 `window.__TAURI__.core.invoke` 由 Tauri 注入,
|
||||
* 消费方一律从这个名字取类型,不要再写 `ReturnType<typeof resolveTauriInvoke>`。
|
||||
*/
|
||||
export type TauriInvoke = <Result = unknown>(
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
) => Promise<Result>;
|
||||
import type { TauriInvoke } from './types';
|
||||
|
||||
/** 返回 undefined 表示不在 Tauri 环境(浏览器预览、测试壳),调用方必须先判空。 */
|
||||
export function resolveTauriInvoke(): TauriInvoke | undefined {
|
||||
return window.__TAURI__?.core?.invoke;
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { resolveTauriInvoke, type TauriInvoke } from '../../app/tauri';
|
||||
import type { LocalProjectDirectoryStatus } from '../../app/types';
|
||||
import { resolveTauriInvoke } from '../../app/tauri';
|
||||
import type { LocalProjectDirectoryStatus, TauriInvoke } from '../../app/types';
|
||||
import {
|
||||
isAbsoluteProjectPath,
|
||||
projectPathHasControlCharacter,
|
||||
@@ -21,17 +21,90 @@ 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];
|
||||
/**
|
||||
* 提权/权限类失败不重试:Rust 侧会重新走 `Start-Process -Verb RunAs -Wait`,
|
||||
* 而提权闸门只存在于单次 invoke 内,重试等于在用户刚点「否」后再弹一次 UAC。
|
||||
* 判据与 config.rs 的 `windows_acl_error_may_need_elevation` 同口径。
|
||||
*/
|
||||
const RECENT_WORKSPACE_ELEVATION_ERROR_MARKERS = [
|
||||
'DACL',
|
||||
'权限',
|
||||
'error 5',
|
||||
'安全对象不属于当前用户',
|
||||
'启用 Windows',
|
||||
'特权',
|
||||
'1300',
|
||||
'AGC ACL 提权修复未成功',
|
||||
];
|
||||
|
||||
type RecentWorkspaceInspection = {
|
||||
path: string;
|
||||
status: LocalProjectDirectoryStatus | null;
|
||||
/** false 表示提权/权限类失败:既不重试,也不驱动整表重查。 */
|
||||
retryable: boolean;
|
||||
};
|
||||
|
||||
function recentWorkspaceFailureIsRetryable(message: string): boolean {
|
||||
return !RECENT_WORKSPACE_ELEVATION_ERROR_MARKERS.some((marker) =>
|
||||
message.includes(marker),
|
||||
);
|
||||
}
|
||||
|
||||
async function inspectRecentWorkspace(
|
||||
invoke: TauriInvoke,
|
||||
workspace: string,
|
||||
): Promise<RecentWorkspaceInspection> {
|
||||
let timeoutHandle: number | undefined;
|
||||
try {
|
||||
const status = await Promise.race([
|
||||
invoke<LocalProjectDirectoryStatus>('inspect_local_project_directory', {
|
||||
projectPath: workspace,
|
||||
}),
|
||||
new Promise<never>((_, reject) => {
|
||||
timeoutHandle = window.setTimeout(
|
||||
() => reject(new Error('项目目录检查超时')),
|
||||
RECENT_WORKSPACE_CHECK_TIMEOUT_MS,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
return { path: workspace, status, retryable: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
path: workspace,
|
||||
status: null,
|
||||
retryable: recentWorkspaceFailureIsRetryable(message),
|
||||
};
|
||||
} finally {
|
||||
if (timeoutHandle !== undefined) {
|
||||
window.clearTimeout(timeoutHandle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectRecentWorkspaceWithRetry(
|
||||
invoke: TauriInvoke,
|
||||
workspace: string,
|
||||
): Promise<RecentWorkspaceInspection> {
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
const inspection = await inspectRecentWorkspace(invoke, workspace);
|
||||
const retryDelayMs = RECENT_WORKSPACE_CHECK_RETRY_DELAYS_MS[attempt];
|
||||
if (
|
||||
inspection.status ||
|
||||
!inspection.retryable ||
|
||||
retryDelayMs === undefined
|
||||
) {
|
||||
return inspection;
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
window.setTimeout(resolve, retryDelayMs);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
|
||||
const [recentWorkspaces, setRecentWorkspaces] =
|
||||
@@ -48,41 +121,13 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
|
||||
const inspectionGenerationRef = useRef(0);
|
||||
const failureRecheckTimerRef = useRef<number | undefined>(undefined);
|
||||
const failureStreakRef = useRef(0);
|
||||
const lastRetryableFailureKeyRef = useRef('');
|
||||
// 提权类失败在用户再次主动操作前不再自动重试。
|
||||
const nonRetryablePathsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
async function inspectRecentWorkspace(
|
||||
invoke: TauriInvoke,
|
||||
workspace: string,
|
||||
): Promise<[string, LocalProjectDirectoryStatus | null]> {
|
||||
let timeoutHandle: number | undefined;
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
invoke<LocalProjectDirectoryStatus>('inspect_local_project_directory', {
|
||||
projectPath: workspace,
|
||||
}),
|
||||
new Promise<never>((_, reject) => {
|
||||
timeoutHandle = window.setTimeout(
|
||||
() => reject(new Error('项目目录检查超时')),
|
||||
RECENT_WORKSPACE_CHECK_TIMEOUT_MS,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
return [workspace, result];
|
||||
} catch {
|
||||
return [workspace, null];
|
||||
} finally {
|
||||
if (timeoutHandle !== undefined) {
|
||||
window.clearTimeout(timeoutHandle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一轮检查结束仍有失败行时,按退避重跑整张列表;连续无失败的一轮会把计数清零。
|
||||
* 这是「一次抖动不能把列表钉死」的兜底:即使无人操作,列表也会自己恢复。
|
||||
*/
|
||||
function scheduleFailureRecheck(failedCount: number) {
|
||||
function scheduleFailureRecheck(retryableFailureCount: number) {
|
||||
window.clearTimeout(failureRecheckTimerRef.current);
|
||||
if (failedCount === 0) {
|
||||
if (retryableFailureCount === 0) {
|
||||
failureStreakRef.current = 0;
|
||||
return;
|
||||
}
|
||||
@@ -107,28 +152,24 @@ 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;
|
||||
// 刷新是增量投影:已完成项目的成功结果保留下来(不能因为另一个坏目录把整张
|
||||
// 列表清空成同一个异常状态),但上一轮的失败结果**不进新投影**——失败项回到
|
||||
// 「检查中」并重新检查。否则一次瞬时失败会把结果原样搬到下一轮,整行永久钉在
|
||||
// 「检查失败」上。
|
||||
const pendingWorkspaces = recentWorkspaces.filter(
|
||||
(workspace) => !nonRetryablePathsRef.current.has(workspace),
|
||||
);
|
||||
let pendingCount = pendingWorkspaces.length;
|
||||
const retryableFailures: string[] = [];
|
||||
const finishRound = () => {
|
||||
setRecentWorkspaceRefreshing(false);
|
||||
// 失败集合变化即重置退避预算,避免长期坏目录吃满新瞬时失败的额度。
|
||||
const failureKey = [...retryableFailures].sort().join('|');
|
||||
if (failureKey !== lastRetryableFailureKeyRef.current) {
|
||||
lastRetryableFailureKeyRef.current = failureKey;
|
||||
failureStreakRef.current = 0;
|
||||
}
|
||||
scheduleFailureRecheck(retryableFailures.length);
|
||||
};
|
||||
// 刷新是增量投影:成功结果保留,但上一轮的失败结果不进新投影,
|
||||
// 失败项回到「检查中」并在本轮重新检查。
|
||||
setRecentWorkspaceStatuses((current) => {
|
||||
const next: Record<string, LocalProjectDirectoryStatus | null> = {};
|
||||
for (const workspace of recentWorkspaces) {
|
||||
@@ -141,9 +182,14 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
|
||||
});
|
||||
setRecentWorkspaceRefreshing(true);
|
||||
|
||||
for (const workspace of recentWorkspaces) {
|
||||
void inspectRecentWorkspaceWithRetry(workspace).then(
|
||||
([projectPath, status]) => {
|
||||
if (pendingCount === 0) {
|
||||
finishRound();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const workspace of pendingWorkspaces) {
|
||||
void inspectRecentWorkspaceWithRetry(invoke, workspace).then(
|
||||
({ path: projectPath, status, retryable }) => {
|
||||
if (
|
||||
disposed ||
|
||||
inspectionGeneration !== inspectionGenerationRef.current ||
|
||||
@@ -151,8 +197,12 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!status) {
|
||||
failedCount += 1;
|
||||
if (status) {
|
||||
nonRetryablePathsRef.current.delete(projectPath);
|
||||
} else if (retryable) {
|
||||
retryableFailures.push(projectPath);
|
||||
} else {
|
||||
nonRetryablePathsRef.current.add(projectPath);
|
||||
}
|
||||
setRecentWorkspaceStatuses((current) => ({
|
||||
...current,
|
||||
@@ -160,8 +210,7 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
|
||||
}));
|
||||
pendingCount -= 1;
|
||||
if (pendingCount === 0) {
|
||||
setRecentWorkspaceRefreshing(false);
|
||||
scheduleFailureRecheck(failedCount);
|
||||
finishRound();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -174,6 +223,8 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
|
||||
}, [recentWorkspaces, recentWorkspaceRefreshKey]);
|
||||
|
||||
function rememberRecentWorkspace(projectPath: string) {
|
||||
// 用户主动打开或新建项目:解除提权类失败的跳过标记。
|
||||
nonRetryablePathsRef.current.clear();
|
||||
setRecentWorkspaces(writeRecentWorkspace(projectPath));
|
||||
setRecentWorkspaceRefreshKey((current) => current + 1);
|
||||
}
|
||||
@@ -183,17 +234,29 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
|
||||
if (!invoke) {
|
||||
return;
|
||||
}
|
||||
const [, status] = await inspectRecentWorkspace(invoke, projectPath);
|
||||
nonRetryablePathsRef.current.delete(projectPath);
|
||||
const inspection = await inspectRecentWorkspaceWithRetry(
|
||||
invoke,
|
||||
projectPath,
|
||||
);
|
||||
if (!recentWorkspacesRef.current.includes(projectPath)) {
|
||||
return;
|
||||
}
|
||||
if (inspection.status) {
|
||||
nonRetryablePathsRef.current.delete(projectPath);
|
||||
} else if (inspection.retryable) {
|
||||
scheduleFailureRecheck(1);
|
||||
} else {
|
||||
nonRetryablePathsRef.current.add(projectPath);
|
||||
}
|
||||
setRecentWorkspaceStatuses((current) => ({
|
||||
...current,
|
||||
[projectPath]: status,
|
||||
[projectPath]: inspection.status,
|
||||
}));
|
||||
}
|
||||
|
||||
function handleRecentWorkspaceRemove(projectPath: string) {
|
||||
nonRetryablePathsRef.current.delete(projectPath);
|
||||
setRecentWorkspaces(removeRecentWorkspace(projectPath));
|
||||
setRecentWorkspaceStatuses((current) => {
|
||||
const { [projectPath]: _removed, ...rest } = current;
|
||||
|
||||
+6
-2
@@ -4,8 +4,12 @@ import {
|
||||
CONVERSATION_INITIAL_VISIBLE_COUNT,
|
||||
CONVERSATION_VISIBLE_STEP,
|
||||
} from '../../../../app/constants';
|
||||
import { resolveTauriInvoke, type TauriInvoke } from '../../../../app/tauri';
|
||||
import type { ChatMessage, DirectTurnCancelView } from '../../../../app/types';
|
||||
import { resolveTauriInvoke } from '../../../../app/tauri';
|
||||
import type {
|
||||
ChatMessage,
|
||||
DirectTurnCancelView,
|
||||
TauriInvoke,
|
||||
} from '../../../../app/types';
|
||||
import { projectRuntimeVisibleError } from '../../../../features/agent-runtime';
|
||||
import { uploadLocalFilesAsAttachments } from '../../../../features/app-shell/useHomeProjectCreation';
|
||||
import {
|
||||
|
||||
@@ -124,8 +124,13 @@ test('单次目录检查失败会就地重试,不会把整行钉成「检查
|
||||
expect(result.current.projectRows[0]?.status).not.toBe('检查失败');
|
||||
});
|
||||
|
||||
test('刷新时重新检查上一轮失败的项目,不沿用失败结果', async () => {
|
||||
test('失败结果不跨轮保留:刷新时该项回到「检查中」并重新检查', async () => {
|
||||
const inspectCounts: Record<string, number> = {};
|
||||
let secondRoundPending: (() => void) | null = null;
|
||||
const secondRoundInspection = new Promise<typeof READY_PROJECT>((resolve) => {
|
||||
secondRoundPending = () => resolve(READY_PROJECT);
|
||||
});
|
||||
let brokenRound = 0;
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command !== 'inspect_local_project_directory') {
|
||||
@@ -134,6 +139,10 @@ test('刷新时重新检查上一轮失败的项目,不沿用失败结果', as
|
||||
const projectPath = String(args?.projectPath ?? '');
|
||||
inspectCounts[projectPath] = (inspectCounts[projectPath] ?? 0) + 1;
|
||||
if (projectPath === '/tmp/broken-project') {
|
||||
brokenRound += 1;
|
||||
if (brokenRound > 2) {
|
||||
return secondRoundInspection;
|
||||
}
|
||||
throw new Error('Tauri IPC 持续失败');
|
||||
}
|
||||
return { ...READY_PROJECT, projectPath };
|
||||
@@ -150,16 +159,50 @@ test('刷新时重新检查上一轮失败的项目,不沿用失败结果', as
|
||||
await waitFor(() => {
|
||||
expect(result.current.projectRows[0]?.status).toBe('检查失败');
|
||||
});
|
||||
const attemptsAfterFirstRun = inspectCounts['/tmp/broken-project'] ?? 0;
|
||||
expect(attemptsAfterFirstRun).toBeGreaterThan(1);
|
||||
expect(inspectCounts['/tmp/broken-project']).toBe(2);
|
||||
|
||||
act(() => {
|
||||
result.current.rememberRecentWorkspace('/tmp/ready-project');
|
||||
});
|
||||
|
||||
// 上一轮的失败结果不进新投影:第二轮在途时该项目显示「检查中」而不是沿用「检查失败」。
|
||||
await waitFor(() => {
|
||||
expect(inspectCounts['/tmp/broken-project']).toBeGreaterThan(
|
||||
attemptsAfterFirstRun,
|
||||
);
|
||||
expect(
|
||||
result.current.projectRows.find(
|
||||
(row) => row.path === '/tmp/broken-project',
|
||||
)?.status,
|
||||
).toBe('检查中');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
secondRoundPending?.();
|
||||
await secondRoundInspection;
|
||||
});
|
||||
});
|
||||
|
||||
test('提权类失败不重试:不放大 UAC 弹窗', 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;
|
||||
throw new Error(
|
||||
'读取待修复私有对象失败:C:\\p\\.agent(DACL 不包含当前用户)',
|
||||
);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
window.localStorage.setItem(
|
||||
'genarrative-ai-game-creator.recent-workspaces.v1',
|
||||
JSON.stringify(['/tmp/elevation-project']),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useRecentProjects(vi.fn()));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.projectRows[0]?.status).toBe('检查失败');
|
||||
});
|
||||
expect(attempts).toBe(1);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user