From 04b738755fdf90f247b9352ff83db5d65016f192 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 23 Sep 2026 12:38:04 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20AGC=20=E6=9C=80?= =?UTF-8?q?=E8=BF=91=E9=A1=B9=E7=9B=AE=E3=80=8C=E6=A3=80=E6=9F=A5=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E3=80=8D=E8=A2=AB=E9=92=89=E6=AD=BB=EF=BC=9A=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E9=80=80=E9=81=BF=E9=87=8D=E8=AF=95=20+=20=E6=8C=89?= =?UTF-8?q?=E8=BD=AE=E9=87=8D=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useRecentProjects:单次目录检查失败先就地重试一次(300ms),不再立刻落成失败状态 - useRecentProjects:增量投影只保留成功结果,上一轮的失败项回到「检查中」并重新检查 - useRecentProjects:一轮结束仍有失败行时按 15s/45s/120s 退避重跑整张列表,连续无失败即清零 - app/tauri:导出命名类型 TauriInvoke,替换 3 处 ReturnType 写法 - tests/recentProjectsHook:新增「单次失败会重试」「刷新时重查失败项」两个用例 - tests/appSurface/home.suite:失败状态改为异步等待(重试后才会落成) --- apps/ai-game-creator-shell/src/app/tauri.ts | 11 ++- .../features/app-shell/useRecentProjects.ts | 75 +++++++++++++++++-- .../useDirectProjectChatController.ts | 4 +- .../tests/appSurface/home.suite.ts | 3 +- .../tests/recentProjectsHook.test.tsx | 72 ++++++++++++++++++ 5 files changed, 153 insertions(+), 12 deletions(-) diff --git a/apps/ai-game-creator-shell/src/app/tauri.ts b/apps/ai-game-creator-shell/src/app/tauri.ts index b1e3d4346..8a8cbd62d 100644 --- a/apps/ai-game-creator-shell/src/app/tauri.ts +++ b/apps/ai-game-creator-shell/src/app/tauri.ts @@ -1,3 +1,12 @@ -export function resolveTauriInvoke() { +/** + * AGC 调用 Rust 命令的唯一入口类型。全局 `window.__TAURI__.core.invoke` 由 Tauri 注入, + * 消费方一律从这个名字取类型,不要再写 `ReturnType`。 + */ +export type TauriInvoke = ( + command: string, + args?: Record, +) => Promise; + +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..93698a88e 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 @@ -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>) { const [recentWorkspaces, setRecentWorkspaces] = @@ -35,9 +46,11 @@ export function useRecentProjects(setStatus: Dispatch>) { const recentWorkspacesRef = useRef(recentWorkspaces); recentWorkspacesRef.current = recentWorkspaces; const inspectionGenerationRef = useRef(0); + const failureRecheckTimerRef = useRef(undefined); + const failureStreakRef = useRef(0); async function inspectRecentWorkspace( - invoke: NonNullable>, + invoke: TauriInvoke, workspace: string, ): Promise<[string, LocalProjectDirectoryStatus | null]> { let timeoutHandle: number | undefined; @@ -63,6 +76,28 @@ export function useRecentProjects(setStatus: Dispatch>) { } } + /** + * 一轮检查结束仍有失败行时,按退避重跑整张列表;连续无失败的一轮会把计数清零。 + * 这是「一次抖动不能把列表钉死」的兜底:即使无人操作,列表也会自己恢复。 + */ + 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>) { 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((resolve) => { + window.setTimeout(resolve, retryDelayMs); + }); + } + }; let disposed = false; let pendingCount = recentWorkspaces.length; - // 保留已完成项目的最后一个独立结果。刷新是增量投影:只有新项目或 - // 尚未完成检查的项目显示“检查中”,不能因为另一个坏目录而把整张列表 - // 清空成同一个异常状态。 + let failedCount = 0; + // 刷新是增量投影:已完成项目的成功结果保留下来(不能因为另一个坏目录把整张 + // 列表清空成同一个异常状态),但上一轮的失败结果**不进新投影**——失败项回到 + // 「检查中」并重新检查。否则一次瞬时失败会把结果原样搬到下一轮,整行永久钉在 + // 「检查失败」上。 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; @@ -89,7 +142,7 @@ export function useRecentProjects(setStatus: Dispatch>) { 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>) { ) { return; } + if (!status) { + failedCount += 1; + } setRecentWorkspaceStatuses((current) => ({ ...current, [projectPath]: status, @@ -105,12 +161,15 @@ export function useRecentProjects(setStatus: Dispatch>) { pendingCount -= 1; if (pendingCount === 0) { setRecentWorkspaceRefreshing(false); + scheduleFailureRecheck(failedCount); } }, ); } return () => { disposed = true; + window.clearTimeout(failureRecheckTimerRef.current); + failureRecheckTimerRef.current = undefined; }; }, [recentWorkspaces, recentWorkspaceRefreshKey]); 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 57356ad13..3d7a55c46 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 @@ -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>, + 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..e351f650a 100644 --- a/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx +++ b/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx @@ -91,3 +91,75 @@ 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 = {}; + 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') { + 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, + ); + }); +}); From 25d331ca0a03252ef4230483efcc799a287956c8 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 23 Sep 2026 13:28:17 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E5=90=8C=E6=AD=A5=20#491=20=E6=BC=8F?= =?UTF-8?q?=E6=94=B9=E7=9A=84=E6=8F=90=E7=A4=BA=E8=AF=8D=E6=96=AD=E8=A8=80?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=20master=20=E4=B8=8A=E4=B8=A4=E6=9D=A1=20AGC?= =?UTF-8?q?=20Rust=20lane=20=E7=9A=84=20CI=20=E7=BA=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tests/provider:工具计划与修复请求的 run_id 断言反转为「不得携带」(#491 已把 runId 移出提示词) - tests/response_stream:4 个 finalization/replan 用例同步 run_id 与 session_id 断言(同一 run 改由 runtime/DB 断言承担) - tests/project_tools:删除确认后的重规划请求同样反转为「不得携带 run_id」 - admin-web:AGC 模板 409 写入错误用例把聚焦断言套进 waitFor,消除 CI 抢跑 flake --- .../src/pages/AdminAgcTemplatesPage.test.tsx | 5 +++- .../src-tauri/src/tests/project_tools.rs | 4 ++- .../src-tauri/src/tests/provider.rs | 7 +++-- .../src-tauri/src/tests/response_stream.rs | 30 ++++++++++++------- 4 files changed, 31 insertions(+), 15 deletions(-) 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-tauri/src/tests/project_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs index 7636655ef..b8fe675e5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs @@ -1011,7 +1011,9 @@ async fn background_agent_runtime_file_delete_confirmation_replans_after_stale_p let replanning_request = receiver .recv_timeout(Duration::from_secs(10)) .expect("same-run replanning request after stale delete approval"); - assert!(replanning_request.contains("design-stale-delete-confirm-run")); + // 提示词不再携带 run/session 标识(team-conventions:提示词不写宿主实现细节); + // 同一 run 由下面的 completed.run_id 判定,这里守住“内部标识不进请求”的边界。 + assert!(!replanning_request.contains("design-stale-delete-confirm-run")); assert!(replanning_request.contains("projectRevisionDrift=true")); assert!(replanning_request.contains("旧动作未执行")); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs index 2834c0a52..8094d65e8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs @@ -5492,7 +5492,10 @@ async fn background_agent_runtime_repairs_malformed_tool_plan_in_same_run() { let initial_request = receiver .recv_timeout(Duration::from_secs(2)) .expect("initial tool plan request"); - assert!(initial_request.contains(run_id)); + // run/session 标识不再进入提示词(team-conventions:AGC 外置智能体提示词只写模型要遵守的 + // 指令与契约,不写宿主实现细节),同一 run 由下面的 runtime 状态与 DB 记录判定; + // 这里改成守住反方向边界——内部标识不得再泄露进请求。 + assert!(!initial_request.contains(run_id)); assert!(!initial_request.contains("上一条输出不符合工具计划协议")); assert!(mock_http_request_json(&initial_request)["tools"] .as_array() @@ -5500,7 +5503,7 @@ async fn background_agent_runtime_repairs_malformed_tool_plan_in_same_run() { let repair_request = receiver .recv_timeout(Duration::from_secs(2)) .expect("tool plan repair request"); - assert!(repair_request.contains(run_id)); + assert!(!repair_request.contains(run_id)); assert!(repair_request.contains("\"role\":\"assistant\"")); assert!(repair_request.contains("格式损坏")); assert!(repair_request.contains("上一条输出不符合工具计划协议")); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs index ccb92dcac..0e8681ab6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs @@ -1957,7 +1957,9 @@ fn finalization_resume_discards_unpersisted_reply_after_revision_drift() { let request = receiver .recv_timeout(Duration::from_secs(2)) .expect("stale prepared finalization must replan"); - assert!(request.contains(run_id)); + // 提示词不再携带 run/session 标识(team-conventions:提示词不写宿主实现细节), + // 同一 run 由下面的 runtime.run_id 判定;这里守住“内部标识不进请求”的边界。 + assert!(!request.contains(run_id)); let runtime = wait_for_agent_runtime_idle(&root, "design-director"); assert_eq!(runtime.phase, "completed"); assert_eq!(runtime.run_id, run_id); @@ -2651,11 +2653,11 @@ async fn background_finalization_replans_same_run_after_cross_agent_revision_dri let initial_plan_request = request_receiver .recv_timeout(Duration::from_secs(2)) .expect("initial convergence plan request"); - assert!(initial_plan_request.contains(run_id)); + assert!(!initial_plan_request.contains(run_id)); let stale_final_reply_request = request_receiver .recv_timeout(Duration::from_secs(2)) .expect("blocked stale final reply request"); - assert!(stale_final_reply_request.contains(run_id)); + assert!(!stale_final_reply_request.contains(run_id)); assert!( !game_creator_agent_runtime_task_lock_is_available(&root, "design-director") .expect("inspect lock while final reply is blocked"), @@ -2677,8 +2679,10 @@ async fn background_finalization_replans_same_run_after_cross_agent_revision_dri let replanned_request = request_receiver .recv_timeout(Duration::from_secs(30)) .expect("same run replanning request"); - assert!(replanned_request.contains(run_id)); - assert!(replanned_request.contains(&session_id)); + // 提示词不再携带 run/session 标识(team-conventions:提示词不写宿主实现细节); + // 同一 run 由本用例末尾的 runtime.run_id / runtime.session_id 判定。 + assert!(!replanned_request.contains(run_id)); + assert!(!replanned_request.contains(&session_id)); assert!(replanned_request.contains("runtime.verification")); assert!(replanned_request.contains("currentRevision=2")); assert!( @@ -2689,13 +2693,13 @@ async fn background_finalization_replans_same_run_after_cross_agent_revision_dri let verified_request = request_receiver .recv_timeout(Duration::from_secs(10)) .expect("request after current revision verification"); - assert!(verified_request.contains(run_id)); + assert!(!verified_request.contains(run_id)); assert!(verified_request.contains("project.verify")); assert!(verified_request.contains("AGENT_RUNTIME_CURRENT_REVISION_OK")); let current_final_reply_request = request_receiver .recv_timeout(Duration::from_secs(30)) .expect("current final reply request"); - assert!(current_final_reply_request.contains(run_id)); + assert!(!current_final_reply_request.contains(run_id)); let runtime = wait_for_agent_runtime_idle(&root, "design-director"); wait_for_provider_handoff_terminal_cleanup(&root, "design-director", run_id); @@ -2830,8 +2834,10 @@ async fn read_only_background_finalization_replans_when_reply_revision_becomes_s let replanned_request = request_receiver .recv_timeout(Duration::from_secs(30)) .expect("read-only same-run replanning request"); - assert!(replanned_request.contains(run_id)); - assert!(replanned_request.contains(&session_id)); + // 提示词不再携带 run/session 标识(team-conventions:提示词不写宿主实现细节); + // 同一 run 由下面的 runtime.run_id / runtime.session_id 判定。 + assert!(!replanned_request.contains(run_id)); + assert!(!replanned_request.contains(&session_id)); assert!(replanned_request.contains("responseRevision=0")); assert!(replanned_request.contains("currentRevision=1")); let runtime = wait_for_agent_runtime_idle(&root, "design-director"); @@ -3024,7 +3030,9 @@ async fn stale_finalization_context_survives_restart_before_same_run_replanning( let replanned_request = request_receiver .recv_timeout(Duration::from_secs(2)) .expect("restart replanning request"); - assert!(replanned_request.contains(run_id)); + // 提示词不再携带 run/session 标识(team-conventions:提示词不写宿主实现细节); + // 同一 run 由本用例末尾的 runtime.run_id / runtime.session_id 判定。 + assert!(!replanned_request.contains(run_id)); assert!(replanned_request.contains("currentRevision=2")); let verified_request = request_receiver .recv_timeout(Duration::from_secs(10)) @@ -3033,7 +3041,7 @@ async fn stale_finalization_context_survives_restart_before_same_run_replanning( let final_reply_request = request_receiver .recv_timeout(Duration::from_secs(2)) .expect("restart final reply request"); - assert!(final_reply_request.contains(run_id)); + assert!(!final_reply_request.contains(run_id)); let runtime = read_game_creator_agent_runtime_at(&root, "design-director") .expect("read completed restart runtime") From 5bdf25cfd7a4f1ecca762fa2d2a06ca88e3f4819 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 23 Sep 2026 16:41:10 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E8=AF=84=E5=AE=A1=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=EF=BC=9A=E6=8F=90=E6=9D=83=E7=B1=BB=E5=A4=B1=E8=B4=A5=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E9=87=8D=E8=AF=95=E3=80=81=E9=87=8D=E5=91=BD=E5=90=8D?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E5=A4=8D=E7=94=A8=E9=87=8D=E8=AF=95=E3=80=81?= =?UTF-8?q?=E9=80=80=E9=81=BF=E9=A2=84=E7=AE=97=E7=BB=91=E5=AE=9A=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E9=9B=86=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useRecentProjects:区分可重试失败与提权/权限类失败,后者不再就地重试、也不驱动整表重查,避免自己驱动 UAC 反复弹窗 - useRecentProjects:提权类失败的项目在用户主动打开/新建项目或重命名刷新前跳过检查 - useRecentProjects:退避重查预算绑定到失败集合,集合变化或整轮无失败即重置,长期坏目录不再吃满额度 - useRecentProjects:重命名后的单条刷新复用同一套就地重试与有界重查 - app/tauri:删除重复的 TauriInvoke 类型,统一引用 app/types 的既有定义 - tests/recentProjectsHook:失败不跨轮保留改为断言在途「检查中」,新增提权类失败不重试用例 - docs:decision-log、pitfalls、生命周期方案与异步闭环方案同步失败自愈口径与提权边界 --- apps/ai-game-creator-shell/src/app/tauri.ts | 10 +- .../features/app-shell/useRecentProjects.ts | 211 ++++++++++++------ .../useDirectProjectChatController.ts | 8 +- .../tests/recentProjectsHook.test.tsx | 55 ++++- .../shared-memory/decision-log.md | 9 +- docs/project-memory/shared-memory/pitfalls.md | 8 + ...】AGC客户端稳定版生命周期大切换-2026-09-14.md | 4 +- ...术方案】AGC异步操作可恢复闭环-2026-09-14.md | 2 + 8 files changed, 214 insertions(+), 93 deletions(-) diff --git a/apps/ai-game-creator-shell/src/app/tauri.ts b/apps/ai-game-creator-shell/src/app/tauri.ts index 8a8cbd62d..39daea483 100644 --- a/apps/ai-game-creator-shell/src/app/tauri.ts +++ b/apps/ai-game-creator-shell/src/app/tauri.ts @@ -1,12 +1,6 @@ -/** - * AGC 调用 Rust 命令的唯一入口类型。全局 `window.__TAURI__.core.invoke` 由 Tauri 注入, - * 消费方一律从这个名字取类型,不要再写 `ReturnType`。 - */ -export type TauriInvoke = ( - command: string, - args?: Record, -) => Promise; +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 93698a88e..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 @@ -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 { + 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] = @@ -48,41 +121,13 @@ export function useRecentProjects(setStatus: Dispatch>) { const inspectionGenerationRef = useRef(0); const failureRecheckTimerRef = useRef(undefined); const failureStreakRef = useRef(0); + const lastRetryableFailureKeyRef = useRef(''); + // 提权类失败在用户再次主动操作前不再自动重试。 + const nonRetryablePathsRef = useRef>(new Set()); - async function inspectRecentWorkspace( - invoke: TauriInvoke, - 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(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>) { 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((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 = {}; for (const workspace of recentWorkspaces) { @@ -141,9 +182,14 @@ export function useRecentProjects(setStatus: Dispatch>) { }); 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>) { ) { 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>) { })); pendingCount -= 1; if (pendingCount === 0) { - setRecentWorkspaceRefreshing(false); - scheduleFailureRecheck(failedCount); + finishRound(); } }, ); @@ -174,6 +223,8 @@ export function useRecentProjects(setStatus: Dispatch>) { }, [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>) { 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 3d7a55c46..24084d798 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 @@ -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 { diff --git a/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx b/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx index e351f650a..7abb15b64 100644 --- a/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx +++ b/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx @@ -124,8 +124,13 @@ test('单次目录检查失败会就地重试,不会把整行钉成「检查 expect(result.current.projectRows[0]?.status).not.toBe('检查失败'); }); -test('刷新时重新检查上一轮失败的项目,不沿用失败结果', async () => { +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') { @@ -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) => { + 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 f1b42991f..71ce80d4f 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-23 AGC 发布前先守可运行原型门禁 - 背景:客户端已经显示“首个可运行原型尚未完成,运行视图暂不可用”,但发布入口仍会先执行用户项目的 `build`,导致未完成原型也进入构建并在后续失败。 @@ -289,7 +296,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 54c8f7deb..8de85604a 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 检查 | 命令输出 |