diff --git a/apps/admin-web/src/pages/AdminAgcTemplatesPage.test.tsx b/apps/admin-web/src/pages/AdminAgcTemplatesPage.test.tsx index 667e44af5..11c3f5493 100644 --- a/apps/admin-web/src/pages/AdminAgcTemplatesPage.test.tsx +++ b/apps/admin-web/src/pages/AdminAgcTemplatesPage.test.tsx @@ -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); diff --git a/apps/ai-game-creator-shell/src/app/tauri.ts b/apps/ai-game-creator-shell/src/app/tauri.ts index b1e3d4346..39daea483 100644 --- a/apps/ai-game-creator-shell/src/app/tauri.ts +++ b/apps/ai-game-creator-shell/src/app/tauri.ts @@ -1,3 +1,6 @@ -export function resolveTauriInvoke() { +import type { TauriInvoke } from './types'; + +/** 返回 undefined 表示不在 Tauri 环境(浏览器预览、测试壳),调用方必须先判空。 */ +export function resolveTauriInvoke(): TauriInvoke | undefined { return window.__TAURI__?.core?.invoke; } diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts b/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts index b8f141ad3..00080005e 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts @@ -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 { + let timeoutHandle: number | undefined; + try { + const status = await Promise.race([ + invoke('inspect_local_project_directory', { + projectPath: workspace, + }), + new Promise((_, 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 { + 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((resolve) => { + window.setTimeout(resolve, retryDelayMs); + }); + } +} export function useRecentProjects(setStatus: Dispatch>) { const [recentWorkspaces, setRecentWorkspaces] = @@ -35,32 +119,28 @@ export function useRecentProjects(setStatus: Dispatch>) { const recentWorkspacesRef = useRef(recentWorkspaces); recentWorkspacesRef.current = recentWorkspaces; const inspectionGenerationRef = useRef(0); + const failureRecheckTimerRef = useRef(undefined); + const failureStreakRef = useRef(0); + const lastRetryableFailureKeyRef = useRef(''); + // 提权类失败在用户再次主动操作前不再自动重试。 + const nonRetryablePathsRef = useRef>(new Set()); - async function inspectRecentWorkspace( - invoke: NonNullable>, - workspace: string, - ): Promise<[string, LocalProjectDirectoryStatus | null]> { - let timeoutHandle: number | undefined; - try { - const result = await Promise.race([ - invoke('inspect_local_project_directory', { - projectPath: workspace, - }), - new Promise((_, 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>) { } 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 = {}; 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>) { ) { 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>) { 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; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index c415be9e0..eec32975d 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -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>, + invoke: TauriInvoke, nextProjectPath: string, input: DirectProjectTurnInput, ) { diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index e4607c0d0..e081afb8b 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -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( diff --git a/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx b/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx index e564a6fca..7abb15b64 100644 --- a/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx +++ b/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx @@ -91,3 +91,118 @@ test('刷新坏项目时保留其它项目已确认的正常状态', async () => await pendingInspection; }); }); + +test('单次目录检查失败会就地重试,不会把整行钉成「检查失败」', async () => { + let attempts = 0; + const invoke = vi.fn( + async (command: string, args?: Record) => { + 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 = {}; + let secondRoundPending: (() => void) | null = null; + const secondRoundInspection = new Promise((resolve) => { + secondRoundPending = () => resolve(READY_PROJECT); + }); + let brokenRound = 0; + const invoke = vi.fn( + async (command: string, args?: Record) => { + 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) => { + 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); +}); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index a5a01ea50..63deeb2db 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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!` 留痕 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 7f0a63bab..017035200 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -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-阶段与提示词注入)。 diff --git a/docs/【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md b/docs/【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md index 63fcf35f1..69dd33d1b 100644 --- a/docs/【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md +++ b/docs/【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md @@ -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`(建项看门狗解围并保留迟到成功、设计运行时初始化失败后保留工作区并可打开) | diff --git a/docs/【技术方案】AGC异步操作可恢复闭环-2026-09-14.md b/docs/【技术方案】AGC异步操作可恢复闭环-2026-09-14.md index 616ef3042..fe08886e7 100644 --- a/docs/【技术方案】AGC异步操作可恢复闭环-2026-09-14.md +++ b/docs/【技术方案】AGC异步操作可恢复闭环-2026-09-14.md @@ -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 检查 | 命令输出 |