Merge pull request '修复 AGC 最近项目「检查失败」被钉死:失败退避重试 + 按轮重查(#490)' (#492) from fix/agc-recent-project-status-retry into master
Project CI / AI game creator shell Rust crates (push) Successful in 1m22s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m50s
Project CI / Backend tests (push) Successful in 3m52s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 5m48s
Project CI / Frontend tests (push) Successful in 2m24s
Project CI / Native shell tests (push) Successful in 6m22s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m14s
Project CI / Repository checks (push) Successful in 2m32s
Project CI / AI game creator shell web tests (push) Successful in 2m7s

Reviewed-on: #492
Reviewed-by: 孔令弘 <ink29535@proton.me>
This commit was merged in pull request #492.
This commit is contained in:
2026-09-23 17:30:59 +08:00
10 changed files with 310 additions and 45 deletions
@@ -339,7 +339,10 @@ test.each([409, 503])(
await confirmWrite();
const message = await screen.findByRole('alert');
const feedback = message.parentElement!;
expect(document.activeElement).toBe(feedback);
// 聚焦发生在 React passive effect 里(AdminAgcTemplatesPage 的 feedback 聚焦 useEffect),
// 而 findByRole 在 alert 节点一挂上就返回,可能早于该 effect 执行;这里等聚焦落地,
// 避免在 CI 负载下抢跑。断言口径不变:焦点最终必须落在提示区而不是弹窗面板。
await waitFor(() => expect(document.activeElement).toBe(feedback));
expect(feedback.tabIndex).toBe(-1);
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
expect(viewport.scrollTop).toBe(20);
+4 -1
View File
@@ -1,3 +1,6 @@
export function resolveTauriInvoke() {
import type { TauriInvoke } from './types';
/** 返回 undefined 表示不在 Tauri 环境(浏览器预览、测试壳),调用方必须先判空。 */
export function resolveTauriInvoke(): TauriInvoke | undefined {
return window.__TAURI__?.core?.invoke;
}
@@ -8,7 +8,7 @@ import {
} from 'react';
import { resolveTauriInvoke } from '../../app/tauri';
import type { LocalProjectDirectoryStatus } from '../../app/types';
import type { LocalProjectDirectoryStatus, TauriInvoke } from '../../app/types';
import {
isAbsoluteProjectPath,
projectPathHasControlCharacter,
@@ -21,6 +21,90 @@ import {
} from './model';
const RECENT_WORKSPACE_CHECK_TIMEOUT_MS = 5_000;
/** 单次检查失败后的就地重试退避,数组长度即重试次数。 */
const RECENT_WORKSPACE_CHECK_RETRY_DELAYS_MS = [300];
/** 一轮结束仍有可重试失败时重跑整张列表的退避,数组长度即重跑次数上限。 */
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] =
@@ -35,32 +119,28 @@ 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);
const lastRetryableFailureKeyRef = useRef('');
// 提权类失败在用户再次主动操作前不再自动重试。
const nonRetryablePathsRef = useRef<Set<string>>(new Set());
async function inspectRecentWorkspace(
invoke: NonNullable<ReturnType<typeof resolveTauriInvoke>>,
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(retryableFailureCount: number) {
window.clearTimeout(failureRecheckTimerRef.current);
if (retryableFailureCount === 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(() => {
@@ -73,24 +153,43 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
}
const inspectionGeneration = ++inspectionGenerationRef.current;
let disposed = false;
let pendingCount = recentWorkspaces.length;
// 保留已完成项目的最后一个独立结果。刷新是增量投影:只有新项目或
// 尚未完成检查的项目显示“检查中”,不能因为另一个坏目录而把整张列表
// 清空成同一个异常状态。
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) {
if (Object.prototype.hasOwnProperty.call(current, workspace)) {
next[workspace] = current[workspace] ?? null;
const status = current[workspace];
if (status) {
next[workspace] = status;
}
}
return next;
});
setRecentWorkspaceRefreshing(true);
for (const workspace of recentWorkspaces) {
void inspectRecentWorkspace(invoke, 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 ||
@@ -98,23 +197,34 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
) {
return;
}
if (status) {
nonRetryablePathsRef.current.delete(projectPath);
} else if (retryable) {
retryableFailures.push(projectPath);
} else {
nonRetryablePathsRef.current.add(projectPath);
}
setRecentWorkspaceStatuses((current) => ({
...current,
[projectPath]: status,
}));
pendingCount -= 1;
if (pendingCount === 0) {
setRecentWorkspaceRefreshing(false);
finishRound();
}
},
);
}
return () => {
disposed = true;
window.clearTimeout(failureRecheckTimerRef.current);
failureRecheckTimerRef.current = undefined;
};
}, [recentWorkspaces, recentWorkspaceRefreshKey]);
function rememberRecentWorkspace(projectPath: string) {
// 用户主动打开或新建项目:解除提权类失败的跳过标记。
nonRetryablePathsRef.current.clear();
setRecentWorkspaces(writeRecentWorkspace(projectPath));
setRecentWorkspaceRefreshKey((current) => current + 1);
}
@@ -124,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;
@@ -5,7 +5,11 @@ import {
CONVERSATION_VISIBLE_STEP,
} from '../../../../app/constants';
import { resolveTauriInvoke } from '../../../../app/tauri';
import type { ChatMessage, DirectTurnCancelView } from '../../../../app/types';
import type {
ChatMessage,
DirectTurnCancelView,
TauriInvoke,
} from '../../../../app/types';
import { projectRuntimeVisibleError } from '../../../../features/agent-runtime';
import { uploadLocalFilesAsAttachments } from '../../../../features/app-shell/useHomeProjectCreation';
import {
@@ -547,7 +551,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,118 @@ 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> = {};
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') {
throw new Error(`unexpected invoke ${command}`);
}
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 };
},
);
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('检查失败');
});
expect(inspectCounts['/tmp/broken-project']).toBe(2);
act(() => {
result.current.rememberRecentWorkspace('/tmp/ready-project');
});
// 上一轮的失败结果不进新投影:第二轮在途时该项目显示「检查中」而不是沿用「检查失败」。
await waitFor(() => {
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\\.agentDACL 不包含当前用户)',
);
},
);
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);
});
@@ -1,5 +1,12 @@
# 决策记录
## 2026-09-23 最近项目检查失败不进终态
- 背景:最近项目列表把一次性的目录检查失败当成终态——5s 超时被吞成 `null`,增量投影又把上一轮的 `null` 原样搬进下一轮,且没有重试或重查入口。AGC 一次 IPC 停顿之后,整张列表会永久停在「检查失败 + 待识别」,首页「最近项目」同时因 `canOpen` 过滤变空,只能重启客户端恢复(issue #490)。
- 决策:单次检查失败先就地重试一次(300ms);失败结果不进新投影(失败项回到「检查中」并重新检查);一轮结束仍有**可重试**失败时按 15s / 45s / 120s 重跑整张列表,重跑上限 3 次,失败集合变化或整轮无失败即重置预算;重命名后的单条刷新复用同一套重试与有界重查。
- 提权边界:Windows ACL 自动提权类失败(`DACL``权限``error 5``安全对象不属于当前用户``特权``1300``AGC ACL 提权修复未成功`)判定为不可重试——重试等于在用户刚点「否」后再弹一次 UAC(提权闸门只存在于单次 invoke 内,进程级没有冷却记忆)。这类项目在用户再次主动打开/新建项目或重命名刷新之前不再自动重试,也不驱动整表重查。
- 验证:`apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx` 覆盖「单次失败就地重试」「失败不跨轮保留」「提权类失败不重试」(前两者在改前代码上必挂);`tests/appSurface/home.suite.ts` 的失败态改为等待最终状态;退避重查用一次性脚本验证持续失败后 15s 自动恢复(脚本未入库)。
## 2026-09-22 筛选控件选中态:类名收敛到 helper,视觉收敛到「实心填充 + 反白文字」
- 背景:`platform-category-chip` 的类名字符串此前在三个宿主各抄一份(共享筛选条 `PlatformResourceFilterBar`、资源画布筛选浮层 `ResourceFilterPanel`、模板库筛选区),「选中的筛选胶囊长什么样」随时会各自漂移;更严重的是选中态本身只用了 `--platform-cool-*` 这组低透明度暖色,实测选中/未选底色对比只有 1.09:1,用户反馈「选中和没选中的颜色看不出差别」。
@@ -308,7 +315,7 @@ Godot 编辑器操控复用既有 AGC 插件宿主、EditorAdapter、Runner 和
## 2026-09-20 最近项目检查保持项目级隔离
- 背景:最近项目刷新会重新检查所有路径。若其中一个目录损坏、超时或不可读,清空整张状态表会让已确认正常的项目暂时全部显示“检查中”,用户只能移除坏项目后看到列表恢复。
- 决策:最近项目状态按路径独立投影;刷新时保留仍在列表中的最后一次结果,只有新增或尚未检查的项目进入“检查中”。检查代次或列表成员变化后,迟到结果不得写回,单个项目的失败不能改变其它项目的可打开状态。
- 决策:最近项目状态按路径独立投影;刷新时保留仍在列表中的最后一次**成功**结果,失败结果不进新投影并在本轮重新检查(见 2026-09-23 条目),只有新增或尚未检查的项目进入“检查中”。检查代次或列表成员变化后,迟到结果不得写回,单个项目的失败不能改变其它项目的可打开状态。
- 验证:`recentProjectsHook.test.tsx` 覆盖“新增慢/坏项目刷新时保留正常项目”;`recentProjectsModel.test.ts``unityProjectOpen.test.tsx` 与前端类型检查一并执行。
## 2026-09-17 GameCreationApp 资源 kind 只保留一份词汇表:严格解析 + `app_log!` 留痕
@@ -1,5 +1,13 @@
# 踩坑与排障记录
## 最近项目一次失败会被钉成终态
- **现象**:AGC 卡住一次后,项目列表每一行都显示「检查失败 + 待识别」,首页「最近项目」变成「暂无最近项目」;现场在后端恢复后逐条复跑 `inspect_local_project_directory`(8 个项目)全部 0ms 成功,界面仍然全红(issue #490)。
- **原因**:单次检查的 5s 超时被吞成 `null` 写入状态表,而刷新用的增量投影是 `next[path] = current[path] ?? null`,把失败结果原样搬进下一轮;effect 只依赖列表与刷新计数器,既没有重试也没有 focus/visibility 重查。于是一次抖动会让整张列表永久停在失败态,首页同时被 `canOpen` 过滤清空。
- **处理**:失败就地重试一次(300ms);失败结果不进新投影;一轮仍有可重试失败时按 15s / 45s / 120s 重跑整表(上限 3 次,失败集合变化即重置预算)。提权/权限类失败(`DACL``权限``error 5``安全对象不属于当前用户``特权``1300``AGC ACL 提权修复未成功`)按不可重试处理,在用户主动打开/新建项目或重命名刷新之前跳过——否则「提权被拒 → 300ms 后重试」会自己驱动 UAC 反复弹窗。
- **验证**`apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx` 的三条用例(「单次失败就地重试」「失败不跨轮保留」「提权类失败不重试」),改前代码上前两条必挂;`tests/appSurface/home.suite.ts` 的失败态断言改为等待最终状态。
- **关联**`apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts``src-tauri/src/config.rs`
## 策划 Agent 提示词中的相对路径不要当作内部实现删去
`project/...` 是 Agent 读写策划工作区的目标路径,`resources/...` 是查找内置分册、模板和例子的资源定位;即使阶段上下文也注入了同一产物路径,提示词里的路径仍是 Agent 需要的契约。清理宿主实现细节时不要误删这些相对路径,具体用法见[策划 Agent 路径说明](../../technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md#6-阶段与提示词注入)。
@@ -29,7 +29,7 @@
### RecentProjectInspection
每个最近项目拥有独立 operation 和结果。检查中、可打开、失败、超时、不存在、非目录、未初始化和可导入不能通过一个全局刷新 gate 互相覆盖。刷新采用增量投影:已确认的项目结果继续保留,只有新增或尚未完成检查的项目显示“检查中”;项目被移除或检查代次变化后,迟到结果不得写回列表。
每个最近项目拥有独立 operation 和结果。检查中、可打开、失败、超时、不存在、非目录、未初始化和可导入不能通过一个全局刷新 gate 互相覆盖。刷新采用增量投影:已确认的项目结果继续保留,只有新增或尚未完成检查的项目显示“检查中”;失败结果不进新投影,失败项在本轮重新检查,并在仍有可重试失败时按有界退避重跑整张列表。提权/权限类失败不重试(重试会再次弹 UAC),在用户主动打开/新建项目或重命名刷新前跳过。项目被移除或检查代次变化后,迟到结果不得写回列表。
### DevStackIdentity
@@ -88,7 +88,7 @@
| --- | --- |
| operation identity 与 stale-result | `clientOperation.test.ts`、认证/home 定向测试 |
| HTTP/auth/Runner | client HTTP/API 测试、Rust cargo check、认证 appSurface |
| 最近项目逐行刷新 | `recentProjectsModel.test.ts`、home appSurface |
| 最近项目逐行刷新 | `recentProjectsModel.test.ts``recentProjectsHook.test.tsx`home appSurface |
| dev-stack 身份 | `scripts/dev.test.ts``start-dev-stack.test.ts`、端口 marker 检查 |
| 本地恢复边界 | 现有 manifest/runtime/resource recovery tests;未确认外部副作用不自动重放 |
| 超时隔离与迟到结果 | `auth.suite.ts`stalled native install 不阻塞后续登录、floor 瞬时失败可重试、围栏超时后迟到安装落地)、`home.suite.ts`(建项看门狗解围并保留迟到成功、设计运行时初始化失败后保留工作区并可打开) |
@@ -24,6 +24,7 @@
2. 最近项目逐项独立检查;单项超时/失败只影响该行,已完成且可打开的项目立即可操作。
3. 首页自动创建状态由 `WorkspaceLauncher` 生命周期持有;切页期间仍防重,迟到结果不能覆盖用户已打开的其它项目。
4. 认证恢复和 Runner 连接继续有明确超时、错误和重试入口;本地 Runner 会话安装/清除的阻塞工作不得占用 Tauri 窗口线程。
5. 最近项目检查失败不进终态:单次失败就地重试一次,失败结果不进增量投影并在本轮重新检查;一轮仍有可重试失败时按 15s / 45s / 120s 有界退避重跑整张列表(失败集合变化即重置预算)。提权/权限类失败不重试——重试等同于再次触发 UAC 提权,这类项目在用户主动打开/新建项目或重命名刷新前跳过。
## 契约与迁移
@@ -35,6 +36,7 @@
| --- | --- | --- |
| 响应体超时 | client auth/http 定向测试 | body 卡住抛出稳定超时,第二次 refresh 请求计数为 2 |
| 最近项目独立完成 | model/controller 定向测试或 appSurface 场景 | A 完成时可打开,B 继续检查 |
| 最近项目失败自愈 | `recentProjectsHook.test.tsx`(单次失败就地重试、失败不跨轮保留、提权类失败不重试) | 一次抖动后整行恢复为可打开;提权被拒时不产生第二次检查(不重复弹 UAC) |
| 首页创建跨页防重 | appSurface 场景 | 切页返回后按钮仍禁用,迟到创建不覆盖已有项目 |
| Runner 会话不阻塞窗口 | Rust 编译检查与登录/退出 UI fence | command 使用 blocking worker,前端使用 45 秒可恢复超时 |
| 现有行为不回归 | typecheck、AGC 定向测试、编码和 diff 检查 | 命令输出 |