diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index a91af7ff4..2c9fe3834 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -145,6 +145,11 @@ export function AuthenticatedClient({ const [authCheckError, setAuthCheckError] = useState(''); const [authCheckRetryKey, setAuthCheckRetryKey] = useState(0); const authCheckRunRef = useRef(0); + /** + * 登录尝试代次。UI 的 45s 围栏只约束"等待":底层 native 提交仍在队列里跑,所以围栏超时后 + * 仍要有人接手这次提交的结果。代次确保只有最近一次登录尝试的迟到结果能改变界面。 + */ + const loginAttemptRef = useRef(0); const [loginMode, setLoginMode] = useState<'code' | 'password'>('code'); const [phone, setPhone] = useState(''); const [code, setCode] = useState(''); @@ -477,6 +482,7 @@ export function AuthenticatedClient({ return; } const loginApiBaseUrl = getClientServerBaseUrl(persistedSelection); + const loginAttempt = (loginAttemptRef.current += 1); setLoginBusy(true); setLoginStatus('正在登录'); const loginGeneration = beginPlatformSessionTransition(); @@ -493,15 +499,43 @@ export function AuthenticatedClient({ password, loginApiBaseUrl, ); - const committedGeneration = await withAuthCheckTimeout( - commitAuthenticatedPlatformSession( - user, - loginGeneration, - loginApiBaseUrl, - ), - AUTH_CHECK_RUNNER_TIMEOUT_MS, - '连接本地运行时超时,请重试或重启客户端', + const commitRequest = commitAuthenticatedPlatformSession( + user, + loginGeneration, + loginApiBaseUrl, ); + let commitFenceExpired = false; + // 围栏只放弃等待,不放弃结果:本地运行时确实装好会话时,界面必须跟着进工作区, + // 否则用户停在登录页、而后端已经认为登录成功(重试也会被已装的会话挡住)。 + void commitRequest + .then((committedGeneration) => { + if ( + committedGeneration === null || + !commitFenceExpired || + loginAttemptRef.current !== loginAttempt || + currentPlatformSessionGeneration() !== committedGeneration + ) { + return; + } + setAuthUser(user); + setAuthCheckError(''); + setAuthStatus('authenticated'); + setCode(''); + setPassword(''); + setLoginStatus('本地运行时登录态已确认'); + }) + .catch(() => undefined); + let committedGeneration: number | null; + try { + committedGeneration = await withAuthCheckTimeout( + commitRequest, + AUTH_CHECK_RUNNER_TIMEOUT_MS, + '连接本地运行时超时,请重试或重启客户端', + ); + } catch (error) { + commitFenceExpired = true; + throw error; + } if (committedGeneration === null) { return; } diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index c9061de45..58a188e6f 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -94,6 +94,7 @@ export function WorkspaceLauncherShell({ createHomeDraftAutomatically, openProject, homeCreationBusy, + homeCreationRecoverableProjectPath, } = homeProject; const switchedToGameRuntime = gameRuntimeSwitch !== null && @@ -524,6 +525,7 @@ export function WorkspaceLauncherShell({ recentProjectRows={recentProjectRows} onCreateDraftAutomatically={createHomeDraftAutomatically} creationBusy={homeCreationBusy} + recoverableCreatedProjectPath={homeCreationRecoverableProjectPath} onProjectsOpen={() => setLauncherView('projects')} onProjectOpen={(path) => { setProjectPath(path); diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts index 56e0413ca..61fb8681a 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts @@ -92,6 +92,37 @@ async function suggestAutomaticProjectName( } } +/** + * 自动建项的兜底期限。 + * + * 建项要跑脚手架和依赖安装,慢是正常的,所以这不是"失败期限"而是"解围期限":到点后不再让首页 + * 入口无限占用工作区闸门(底层创建不会被取消,迟到成功仍会照常进入项目)。没有这个期限时, + * 一次卡死的 `create_automatic_local_game_project` 会让首页之后的打开/新建全部无法进行。 + */ +const HOME_CREATION_DEADLINE_MS = 10 * 60_000; +const HOME_CREATION_WATCHDOG_MESSAGE = + '工作区创建超过 10 分钟仍未返回;可先打开其它项目,或在项目列表查看已创建的工作区'; + +/** + * 建项已经落盘、但没能进入项目时,给首页一个恢复入口(打开已创建的工作区)。 + * 只有"已经知道项目路径且当前没有在跑"的阶段才提示,避免和进行中的建项打架。 + */ +function resolveRecoverableHomeProjectPath( + operation: ClientOperation< + 'home-create', + { draft: HomeDraft; startMode: ProjectStartMode } + > | null, +) { + if (!operation) return ''; + if ( + operation.phase !== 'retryable-failure' && + operation.phase !== 'unknown' + ) { + return ''; + } + return operation.scope.projectPath ?? ''; +} + export function useHomeProjectCreation({ setStatus, setLauncherView, @@ -146,6 +177,14 @@ export function useHomeProjectCreation({ * 一次代次,await 回来时已经不是最新代次的结果整体丢弃。 */ const projectEntryTokenRef = useRef(0); + /** + * 首页自动建项的尝试代次与"底层创建仍未返回"标记。 + * + * 看门狗到点后会放开工位闸门(让用户还能打开其它项目),但底层创建仍在跑。此时既不允许 + * 并发再建一个项目(会产生重复工作区),也不能让迟到的创建结果强行劫持用户已经打开的别的项目。 + */ + const homeCreationAttemptRef = useRef(0); + const homeCreationUnresolvedRef = useRef(false); function validateProjectPath(nextProjectPath: string) { const trimmedProjectPath = nextProjectPath.trim(); @@ -667,14 +706,21 @@ export function useHomeProjectCreation({ if (projectActionRef.current || homeCreationIsBusy()) { return '已有项目操作进行中,请稍候'; } + if (homeCreationUnresolvedRef.current) { + // 上一次建项的底层调用还没返回(可能已经超过看门狗期限)。此时放行会真的建出第二个 + // 工作区,所以只挡"再建一个",不挡打开/查看已有项目。 + return '上一次工作区创建仍未返回,请稍候,或重启客户端后再试'; + } const invoke = resolveTauriInvoke(); if (!invoke) { throw new Error('需要在陶泥儿客户端内运行'); } + const attempt = (homeCreationAttemptRef.current += 1); + const isCurrentAttempt = () => homeCreationAttemptRef.current === attempt; const operation = createClientOperation( 'home-create', { draft, startMode }, - { deadlineMs: null, cancellable: false }, + { deadlineMs: HOME_CREATION_DEADLINE_MS, cancellable: false }, ); setHomeCreationOperation(transitionClientOperation(operation, 'network')); // This action is owned by WorkspaceLauncher rather than HomeView. The @@ -682,62 +728,123 @@ export function useHomeProjectCreation({ // the guard while project creation or first-turn import is still running. projectActionRef.current = 'creating'; setProjectAction('creating'); + homeCreationUnresolvedRef.current = true; setStatus('正在创建工作区'); - try { - const suggestedName = options.suggestName - ? await suggestAutomaticProjectName(invoke, draft) - : null; - const result = await invoke( - 'create_automatic_local_game_project', - { - name: suggestedName, - planning: startMode === 'planning', - }, - ); - setHomeCreationOperation( - transitionClientOperation(operation, 'project', { - scope: { projectPath: result.projectPath }, - }), - ); - try { - await enterCreatedHomeProject( - invoke, - result, - draft.creationType, - draft.prompt, - draft.attachments, - startMode, - ); + let createdProjectPath: string | undefined; + let entryTokenAtWatchdog: number | null = null; + const operationScope = () => + createdProjectPath ? { scope: { projectPath: createdProjectPath } } : {}; + let watchdogId: number | undefined; + /** + * 看门狗到点后**只解围不取消**:首页入口立刻拿回控制权(否则 `await` 不结束, + * 首页按钮会一直禁用),而底层建项继续在后台跑;它真的成功时会照常进入项目。 + * 这就是为什么这里用 `return string` 而不是抛错——首页的 catch 会把错误统一压成 + * 「创建未完成,请重试」,反而丢掉"只是慢"这个信息。 + */ + const watchdog = new Promise((resolve) => { + watchdogId = window.setTimeout(() => { + if (!isCurrentAttempt() || !homeCreationUnresolvedRef.current) return; + entryTokenAtWatchdog = projectEntryTokenRef.current; setHomeCreationOperation( - transitionClientOperation(operation, 'success', { + transitionClientOperation(operation, 'unknown', operationScope()), + ); + setStatus(HOME_CREATION_WATCHDOG_MESSAGE); + if (projectActionRef.current === 'creating') { + projectActionRef.current = null; + setProjectAction(null); + } + resolve(HOME_CREATION_WATCHDOG_MESSAGE); + }, HOME_CREATION_DEADLINE_MS); + }); + /** 后台继续跑的建项主体:用户可见的等待由 `watchdog` 兜底,这里只负责最终落定。 */ + const creation = (async () => { + try { + const suggestedName = options.suggestName + ? await suggestAutomaticProjectName(invoke, draft) + : null; + const result = await invoke( + 'create_automatic_local_game_project', + { + name: suggestedName, + planning: startMode === 'planning', + }, + ); + createdProjectPath = result.projectPath; + setHomeCreationOperation( + transitionClientOperation(operation, 'project', { scope: { projectPath: result.projectPath }, }), ); - setStatus('已创建工作区,正在开始智能创作'); - return '已创建工作区并进入项目开发'; + if ( + entryTokenAtWatchdog !== null && + projectEntryTokenRef.current !== entryTokenAtWatchdog + ) { + // 看门狗之后用户已经进了别的项目:工作区确实建好了,但不能在此时把工作区切过去。 + rememberRecentWorkspace(result.projectPath); + setHomeCreationOperation( + transitionClientOperation(operation, 'retryable-failure', { + scope: { projectPath: result.projectPath }, + }), + ); + setStatus('工作区已创建;可在项目列表打开'); + return '工作区已创建,可从项目列表打开'; + } + try { + await enterCreatedHomeProject( + invoke, + result, + draft.creationType, + draft.prompt, + draft.attachments, + startMode, + ); + setHomeCreationOperation( + transitionClientOperation(operation, 'success', { + scope: { projectPath: result.projectPath }, + }), + ); + setStatus('已创建工作区,正在开始智能创作'); + return '已创建工作区并进入项目开发'; + } catch (error) { + // 项目目录已经建好了:把它登记进最近项目,用户可以直接打开,不必重新建一遍。 + rememberRecentWorkspace(result.projectPath); + setHomeCreationOperation( + transitionClientOperation(operation, 'retryable-failure', { + scope: { projectPath: result.projectPath }, + }), + ); + const message = `工作区已创建;首条需求投递失败:${ + error instanceof Error ? error.message : String(error) + }`; + setStatus(message); + throw new Error(message); + } } catch (error) { setHomeCreationOperation( - transitionClientOperation(operation, 'retryable-failure', { - scope: { projectPath: result.projectPath }, - }), + transitionClientOperation( + operation, + 'retryable-failure', + operationScope(), + ), ); - const message = `工作区已创建;首条需求投递失败:${ - error instanceof Error ? error.message : String(error) - }`; - setStatus(message); - throw new Error(message); + if (createdProjectPath) { + rememberRecentWorkspace(createdProjectPath); + } + throw error; + } finally { + if (watchdogId !== undefined) { + window.clearTimeout(watchdogId); + } + homeCreationUnresolvedRef.current = false; + if (isCurrentAttempt() && projectActionRef.current === 'creating') { + projectActionRef.current = null; + setProjectAction(null); + } } - } catch (error) { - setHomeCreationOperation( - transitionClientOperation(operation, 'retryable-failure'), - ); - throw error; - } finally { - if (projectActionRef.current === 'creating') { - projectActionRef.current = null; - setProjectAction(null); - } - } + })(); + // 后台主体不会因为竞速落定而停止:这里只防止它变成未处理的拒绝。 + void creation.catch(() => undefined); + return await Promise.race([creation, watchdog]); } async function pickAndOpenProject() { @@ -850,6 +957,9 @@ export function useHomeProjectCreation({ projectBusy: projectAction !== null, homeCreationOperation, homeCreationBusy: homeCreationIsBusy(), + homeCreationRecoverableProjectPath: resolveRecoverableHomeProjectPath( + homeCreationOperation, + ), pendingNonEmptyProject, resetLauncherHomeDraft, startGameFromApprovedGdd, diff --git a/apps/ai-game-creator-shell/src/services/platformSession.ts b/apps/ai-game-creator-shell/src/services/platformSession.ts index ba708b774..8f40a9ef6 100644 --- a/apps/ai-game-creator-shell/src/services/platformSession.ts +++ b/apps/ai-game-creator-shell/src/services/platformSession.ts @@ -43,7 +43,16 @@ let platformSessionOperation: ClientOperation< 'auth-transition', { userId: string | null } > | null = null; -let platformSessionNativeMutationTail: Promise = Promise.resolve(); +/** + * 本地会话写入的排队闸门。 + * + * Rust 侧 `install_platform_session_in` / `clear_platform_session_in` 按 generation 单调校验: + * 更旧的 install 与更旧的 clear 都会被拒绝。因此渲染层必须保证的只有"新 generation 不被旧调用 + * 无限挡住",而不需要让队列永远等下去。这里给闸门加一个上限:底层 invoke 迟迟不返回(例如 + * Runner 卡住、IPC 不回调)时,后续登录/退出仍能继续推进,迟到的旧写入由 Rust 按代次拒绝。 + */ +const PLATFORM_SESSION_NATIVE_MUTATION_ABANDONMENT_MS = 60_000; +let platformSessionNativeMutationGate: Promise = Promise.resolve(); const platformSessionRefreshListeners = new Set(); const platformSessionGenerationListeners = @@ -104,15 +113,34 @@ async function clearNativePlatformSession(generation: number) { await invoke('clear_platform_account_session', { generation }); } +function waitForNativeMutationAbandonment( + settled: Promise, + timeoutMs: number, +) { + let timerId: number | undefined; + const abandoned = new Promise((resolve) => { + timerId = window.setTimeout(resolve, timeoutMs); + }); + return Promise.race([settled, abandoned]).finally(() => { + if (timerId !== undefined) { + window.clearTimeout(timerId); + } + }); +} + function enqueuePlatformSessionNativeMutation( operation: () => Promise, + abandonmentMs = PLATFORM_SESSION_NATIVE_MUTATION_ABANDONMENT_MS, ): Promise { - const pending = platformSessionNativeMutationTail + const pending = platformSessionNativeMutationGate .catch(() => undefined) .then(operation); - platformSessionNativeMutationTail = pending.then( - () => undefined, - () => undefined, + platformSessionNativeMutationGate = waitForNativeMutationAbandonment( + pending.then( + () => undefined, + () => undefined, + ), + abandonmentMs, ); return pending; } @@ -135,7 +163,15 @@ async function readNativePlatformSessionGenerationFloor() { async function reserveNativePlatformSessionGeneration() { platformNativeGenerationFloorPromise ??= readNativePlatformSessionGenerationFloor(); - const nativeGenerationFloor = await platformNativeGenerationFloorPromise; + let nativeGenerationFloor: number; + try { + nativeGenerationFloor = await platformNativeGenerationFloorPromise; + } catch (error) { + // 一次瞬时失败(IPC 抖动、Runner 刚重启)不能被缓存成"永久失败":否则本次渲染进程 + // 内的后续登录/退出都会在同一个已 reject 的 promise 上失败,用户重试也不会重新读取。 + platformNativeGenerationFloorPromise = null; + throw error; + } platformNativeGeneration = Math.max( platformNativeGeneration + 1, platformAuthGeneration, @@ -486,7 +522,7 @@ export function resetPlatformSessionStateForTests() { desiredPlatformSession = null; platformSessionRefreshPromise = null; platformSessionOperation = null; - platformSessionNativeMutationTail = Promise.resolve(); + platformSessionNativeMutationGate = Promise.resolve(); platformSessionRefreshListeners.clear(); platformSessionGenerationListeners.clear(); } diff --git a/apps/ai-game-creator-shell/src/view/home/index.tsx b/apps/ai-game-creator-shell/src/view/home/index.tsx index 6e6a82824..d1470cb25 100644 --- a/apps/ai-game-creator-shell/src/view/home/index.tsx +++ b/apps/ai-game-creator-shell/src/view/home/index.tsx @@ -113,6 +113,10 @@ type HomeViewProps = { startMode: ProjectStartMode, ) => Promise; creationBusy?: boolean; + /** + * 建项已经落盘、但当前没能进入项目时给出该项目路径,让用户直接打开而不是重建一遍。 + */ + recoverableCreatedProjectPath?: string; onProjectsOpen: () => void; onProjectOpen: (path: string) => void; onProjectPick: () => void; @@ -125,6 +129,7 @@ export default function HomeView({ recentProjectRows, onCreateDraftAutomatically, creationBusy = false, + recoverableCreatedProjectPath = '', onProjectsOpen, onProjectOpen, onProjectPick, @@ -295,6 +300,21 @@ export default function HomeView({ {status}

) : null} + {recoverableCreatedProjectPath && !creationBusy ? ( +

+ 已创建的工作区:{recoverableCreatedProjectPath} + +

+ ) : null}
{ + let floorReads = 0; + const invoke = vi.fn(async (command: string, payload?: unknown) => { + if (command === 'read_platform_account_session_generation') { + floorReads += 1; + if (floorReads === 1) { + throw new Error('runner not ready'); + } + return 12; + } + if ( + command === 'install_platform_account_session' || + command === 'clear_platform_account_session' + ) { + expect(payload).toEqual( + expect.objectContaining({ generation: expect.any(Number) }), + ); + } + return null; + }); + window.__TAURI__ = { core: { invoke } }; + const firstGeneration = beginPlatformSessionTransition(); + window.localStorage.setItem( + 'genarrative.auth.access-token.v1', + 'retry-floor-token', + ); + + await expect( + commitAuthenticatedPlatformSession(testAuthUser, firstGeneration), + ).rejects.toThrow('runner not ready'); + + // 瞬时读取失败不能被缓存成永久失败:第二次登录必须重新读取并成功。 + const secondGeneration = beginPlatformSessionTransition(); + window.localStorage.setItem( + 'genarrative.auth.access-token.v1', + 'retry-floor-token', + ); + await expect( + commitAuthenticatedPlatformSession(testAuthUser, secondGeneration), + ).resolves.toEqual(expect.any(Number)); + expect(floorReads).toBeGreaterThan(1); + expect(invoke).toHaveBeenLastCalledWith( + 'install_platform_account_session', + expect.objectContaining({ + userId: testAuthUser.id, + accessToken: 'retry-floor-token', + }), + ); + }); + + it('does not let a stalled native install wedge the next login', async () => { + vi.useFakeTimers(); + const installedTokens: string[] = []; + let releaseStalledInstall: (() => void) | null = null; + const invoke = vi.fn(async (command: string, payload?: unknown) => { + if (command === 'install_platform_account_session') { + installedTokens.push( + String((payload as { accessToken?: string })?.accessToken), + ); + if (installedTokens.length === 1) { + await new Promise((resolve) => { + releaseStalledInstall = resolve; + }); + } + } + return null; + }); + window.__TAURI__ = { core: { invoke } }; + + const stalledGeneration = beginPlatformSessionTransition(); + window.localStorage.setItem( + 'genarrative.auth.access-token.v1', + 'stalled-token', + ); + const stalled = commitAuthenticatedPlatformSession( + testAuthUser, + stalledGeneration, + ); + await vi.advanceTimersByTimeAsync(0); + expect(installedTokens).toEqual(['stalled-token']); + + const retryGeneration = beginPlatformSessionTransition(); + window.localStorage.setItem( + 'genarrative.auth.access-token.v1', + 'retry-token', + ); + const retry = commitAuthenticatedPlatformSession( + testAuthUser, + retryGeneration, + ); + await vi.advanceTimersByTimeAsync(60_000); + + expect(installedTokens).toEqual(['stalled-token', 'retry-token']); + await expect(retry).resolves.toEqual(expect.any(Number)); + expect( + window.localStorage.getItem('genarrative.auth.access-token.v1'), + ).toBe('retry-token'); + + // 迟到的旧 install 只影响它自己:Rust 按 generation 拒绝过期写入, + // 渲染层保持新会话为准。 + releaseStalledInstall?.(); + await expect(stalled).resolves.toBeNull(); + expect( + window.localStorage.getItem('genarrative.auth.access-token.v1'), + ).toBe('retry-token'); + vi.useRealTimers(); + }); + + it('adopts a login whose native session install finishes after the UI fence', async () => { + vi.useFakeTimers(); + let releaseInstall: (() => void) | null = null; + const invoke = vi.fn(async (command: string) => { + if (command === 'install_platform_account_session') { + await new Promise((resolve) => { + releaseInstall = resolve; + }); + } + return null; + }); + window.__TAURI__ = { core: { invoke } }; + vi.spyOn(globalThis, 'fetch').mockImplementation( + async (input: RequestInfo | URL) => { + const url = String(input); + if (url === '/api/auth/refresh') { + return new Response('', { status: 401 }); + } + if (url === '/api/auth/phone/login') { + return new Response( + JSON.stringify({ + token: 'late-install-token', + user: { ...testAuthUser, loginMethod: 'phone' }, + created: false, + referral: null, + }), + { status: 200 }, + ); + } + throw new Error(`unexpected fetch ${url}`); + }, + ); + + render( + React.createElement(AuthenticatedClient, null, ({ user }) => + React.createElement('main', { 'aria-label': '已登录' }, user.id), + ), + ); + for (let i = 0; i < 5; i += 1) { + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + } + fireEvent.change(screen.getByLabelText('手机号'), { + target: { value: '13800000000' }, + }); + fireEvent.change(screen.getByLabelText('验证码'), { + target: { value: '123456' }, + }); + fireEvent.click(screen.getByRole('button', { name: '登录' })); + + await act(async () => { + await vi.advanceTimersByTimeAsync(45_000); + }); + expect( + screen.getByText('连接本地运行时超时,请重试或重启客户端'), + ).not.toBeNull(); + expect(screen.queryByLabelText('已登录')).toBeNull(); + + // 围栏只放弃等待:本地运行时确实装好会话后,界面必须跟着进工作区。 + releaseInstall?.(); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(screen.queryByLabelText('已登录')).not.toBeNull(); + expect( + window.localStorage.getItem('genarrative.auth.access-token.v1'), + ).toBe('late-install-token'); + vi.useRealTimers(); + }); + it('keeps the previous renderer session authoritative when replacement install is rejected', async () => { const invoke = vi.fn(async (_command: string, payload?: unknown) => { const userId = (payload as { userId?: string } | undefined)?.userId; 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 afd12de06..8f09ca84c 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -1835,6 +1835,146 @@ export function registerHomeProjectCreationTests() { expect(screen.queryByText('自动创建测试结束')).toBeNull(); }); + it('unblocks the home launcher when automatic creation never returns', async () => { + vi.useFakeTimers(); + const automaticProjectPath = '/tmp/home-stalled-project'; + const manifest = createGameCreationAppManifest( + 'home-stalled-project', + '卡住的自动建项', + ); + let resolveAutomaticProject: + | ((result: Record) => void) + | null = null; + const invoke = vi.fn(async (command: string) => { + if (command === 'create_automatic_local_game_project') { + return await new Promise((resolve) => { + resolveAutomaticProject = resolve; + }); + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; + renderLauncherAt('/?launcher', 'home', true); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + const promptInput = screen.getByLabelText('创作想法'); + nativeClipboardMock.text = '做一个挂机游戏'; + fireEvent.paste(promptInput); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + fireEvent.click(screen.getByRole('button', { name: '开启创作' })); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect( + (screen.getByRole('button', { name: '开启创作' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + + // 超过兜底期限:首页入口必须解围,不能永远卡在"正在创建工作区"。 + await act(async () => { + await vi.advanceTimersByTimeAsync(10 * 60_000); + }); + expect(screen.getByText(/工作区创建超过 10 分钟/)).not.toBeNull(); + expect( + (screen.getByRole('button', { name: '开启创作' }) as HTMLButtonElement) + .disabled, + ).toBe(false); + + // 解围不等于放弃底层创建,也不等于允许再建第二个工作区。 + fireEvent.click(screen.getByRole('button', { name: '开启创作' })); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(screen.getByText(/上一次工作区创建仍未返回/)).not.toBeNull(); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'create_automatic_local_game_project', + ), + ).toHaveLength(1); + + // 迟到的成功仍然要进项目:用户不该因为慢就丢掉这次创建。 + await act(async () => { + resolveAutomaticProject?.({ + projectPath: automaticProjectPath, + manifestPath: `${automaticProjectPath}/.agent/manifest.json`, + manifest, + }); + await vi.advanceTimersByTimeAsync(0); + }); + expect(screen.getByLabelText('项目开发工作台')).not.toBeNull(); + cleanup(); + vi.useRealTimers(); + }); + + it('keeps the created workspace recoverable when design runtime setup fails', async () => { + const projectPath = '/tmp/home-design-mode-failure'; + const manifest = createGameCreationAppManifest( + 'home-design-mode-failure', + '策划初始化失败项目', + ); + const supervisorHarness = createProjectSupervisorRuntimeHarness({ + projectPath, + initialSessionExists: false, + }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'create_automatic_local_game_project') { + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'set_design_agent_runtime_mode') { + throw new Error('design runtime unavailable'); + } + if (command === 'inspect_local_project_directory') { + return { + exists: true, + isDirectory: true, + isGameCreatorProject: true, + isCocosProject: false, + godotProjectRoot: null, + }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + return supervisorHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: supervisorHarness.listen }, + }; + renderLauncherAt('/?launcher', 'home', true); + + // 做方案走立项链路:建项之后还要写策划运行时,这一步失败时项目目录已经存在。 + fireEvent.click(screen.getByRole('button', { name: '做方案' })); + const promptInput = screen.getByLabelText('创作想法'); + nativeClipboardMock.text = '做一个塔防游戏'; + fireEvent.paste(promptInput); + await waitFor(() => { + expect(promptInput.textContent).toContain('做一个塔防游戏'); + }); + fireEvent.keyDown(promptInput, { key: 'Enter', code: 'Enter' }); + + expect(await screen.findByText('创建未完成,请重试')).not.toBeNull(); + expect(screen.getByText(`已创建的工作区:${projectPath}`)).not.toBeNull(); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('inspect_local_project_directory', { + projectPath, + }); + }); + + fireEvent.click(screen.getByRole('button', { name: '打开已创建的工作区' })); + expect(await screen.findByLabelText('项目开发工作台')).not.toBeNull(); + }); + it.each([ ['做方案', false], ['做方案', true], diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts index a7dc65789..79b577243 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts @@ -1893,7 +1893,7 @@ export function registerProjectCommandTests() { expect( await screen.findByText( - '当前仅支持确认 project.index、project.status、project.checkpoint、project.diff、project.restore、project.export_package、project.export_list、file.list、file.read、memory.read、memory.write、memory.delete、asset.register、asset.list、task.list、task.create、task.update、agent.run_status、agent.kill、agent.retry、agent.resume、agent.delegate、agent.spawn_isolated、agent.schedule_ready、agent.audit、agent.trace_read、preview.status、preview.start、preview.validate、preview.open、preview.stop、canvas.project_sync、canvas.asset_import、canvas.asset_generate、canvas.export_import、conversation.read 和 conversation.write。', + '当前仅支持确认 project.index、project.status、project.bootstrap、project.checkpoint、project.diff、project.restore、project.export_package、project.export_list、file.list、file.read、memory.read、memory.write、memory.delete、asset.register、asset.list、task.list、task.create、task.update、agent.run_status、agent.kill、agent.retry、agent.resume、agent.delegate、agent.spawn_isolated、agent.schedule_ready、agent.audit、agent.trace_read、preview.status、preview.start、preview.validate、preview.open、preview.stop、canvas.project_sync、canvas.asset_import、canvas.asset_generate、canvas.export_import、conversation.read 和 conversation.write。', ), ).not.toBeNull(); expect(screen.queryByText('project.policy_write')).toBeNull(); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index a72200ff0..4df507394 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,14 @@ # 踩坑与排障记录 +## 2026-09-14 UI 超时围栏只能放弃等待,不能放弃结果;排队闸门不能无限等 + +- **现象**:登录/建项在 UI 上"超时"后报错,用户重试仍然无效;界面停在原页面,而后端/Runner 其实已经接受了这次操作(登录后本机登录态已装好、项目目录已建好)。 +- **原因**:两个独立缺陷叠加。(1) `withAuthCheckTimeout` 一类围栏用 `Promise.race` 只让界面提前失败,底层 native mutation 仍在队列里跑;而队列尾是"无限等上一次完成"的串接,一次卡住的 invoke 会让之后每次登录/退出都排在它后面(故障注入:连续两次登录只产生 1 次 install 调用)。(2) `platformNativeGenerationFloorPromise` 用 `??=` 缓存 promise,一次瞬时读取失败被缓存成永久失败。 +- **处理**:(1) 围栏超时后仍要有人接手结果——`AuthenticatedClient` 用尝试代次 + `currentPlatformSessionGeneration()` 判定,迟到成功才写回界面,绝不覆盖更新的尝试;(2) native 写入队列改成带解围期限的闸门(`PLATFORM_SESSION_NATIVE_MUTATION_ABANDONMENT_MS`)。普通队列"串行"看起来更安全,但本地会话写入的真正不变量在 Rust:`install_platform_session_in` / `clear_platform_session_in` 按 generation 单调拒绝更旧写入,所以渲染层只要保证新 generation 不被旧调用永久挡住即可;(3) 首页 `home-create` 从 `deadlineMs: null` 改为有兜底期限,到点用 `Promise.race` 返回提示字符串解围(**不要抛错**:首页 catch 会把错误统一压成「创建未完成,请重试」,反而丢掉"只是慢")而底层创建继续跑,迟到成功照常进项目;已建好的工作区要登记最近项目并留「打开已创建的工作区」入口。 +- **易错点**:失败分支里不要用**初始** operation 去覆盖已经带上 `scope.projectPath` 的状态——`transitionClientOperation(初始 operation, ...)` 会把内层写好的路径丢掉,导致"项目已建好但用户拿不到"。看门狗解围后仍要保留"底层创建未返回"标记:放行会真的建出第二个工作区;但这条只挡"再建一个",不能挡打开已有项目。 +- **验证**:`apps/ai-game-creator-shell/tests/appSurface.test.ts`(`auth.suite.ts` 的 stalled install / floor 瞬时失败 / 围栏后迟到安装;`home.suite.ts` 的建项看门狗与设计运行时初始化失败后的恢复入口)。 +- **关联**:`apps/ai-game-creator-shell/src/services/platformSession.ts`、`src/app/AuthenticatedClient.tsx`、`src/features/app-shell/useHomeProjectCreation.ts`、`src-tauri/src/platform_session.rs`、`docs/【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md` + ## 2026-09-13 Cocos 操作必须核对实际回执与引擎就绪状态 - named pipe 使用真实换行分帧;测试客户端若写入字面量反斜杠 n,服务端不会执行请求。不能仅凭这类超时推断 Scene WebView 卡死,更不能重放不确定写操作。 diff --git a/docs/【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md b/docs/【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md index bf3a532ff..f5abedfa0 100644 --- a/docs/【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md +++ b/docs/【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md @@ -59,6 +59,15 @@ - 稳定版基础切换里程碑已完成;后续入口只允许复用该合同,不再新增 component-level busy/ref 状态机。 - Planning/DirectProject/资源生成/预览已有各自 durable operation 或 request scope;本轮只补统一投影与身份校验,不重写其持久化账本。 +### 生命周期残留收口(本轮) + +上一轮在 master(`0f829cd25`)上做故障注入复现出四类"每走一步都卡住"的残留,本轮按"超时后真正隔离并核对底层操作"收口,不再新增 operation 字段: + +1. **本地会话写入队列不再被卡死**:`platformSession.ts` 的 native mutation 队列改为带"解围期限"的闸门(60s)。Rust `install_platform_session_in` / `clear_platform_session_in` 本来就按 generation 单调校验(更旧的 install/clear 一律拒绝),所以渲染层只需保证新 generation 不被旧调用无限挡住;迟到的旧写入由 Rust 拒绝。 +2. **generation floor 读取失败可重试**:`reserveNativePlatformSessionGeneration` 不再把一次瞬时失败缓存成永久失败(原先 `??=` 缓存了已 reject 的 promise,导致同一次渲染进程内后续登录/退出全部失败)。 +3. **登录围栏超时后的迟到结果必须落地**:UI 的 45s 围栏只放弃等待,不再放弃结果。本地运行时确实装好会话时,界面跟着进入工作区,避免"后端已登录、前端停在登录页"。 +4. **首页自动建项有兜底期限与恢复入口**:`home-create` 从 `deadlineMs: null` 改为 10 分钟兜底期限;到点后首页入口解围(底层创建继续在后台跑,迟到成功照常进项目),并把已建好的工作区登记进最近项目、在首页给出「打开已创建的工作区」入口。外层 catch 不再用初始 operation 覆盖内层已写入的 `scope.projectPath`;底层创建未返回期间只挡"再建一个",不挡打开已有项目。 + ## 现役边界审计 以下能力在本次切换前已经具备 durable 恢复或失败关闭合同,因此本轮按现有实现接入统一投影,不重复重写: @@ -82,4 +91,17 @@ | 最近项目逐行刷新 | `recentProjectsModel.test.ts`、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`(建项看门狗解围并保留迟到成功、设计运行时初始化失败后保留工作区并可打开) | | 完整流程 | AGC appSurface、开发栈 smoke;真实 Provider/安装包另行记录 | + +### 本轮门禁执行记录 + +- `npm --prefix apps/ai-game-creator-shell run typecheck`(含 skill-pack、check-config)通过。 +- `apps/ai-game-creator-shell/tests/appSurface.test.ts` 428/428 通过(含本轮新增 5 个故障注入回归)。 +- 定向:`clientHttp`、`clientApi`、`clientOperation`、`recentProjectsModel`、`clientRuntimeErrorBoundary`、`sessionPreview`、`start-dev-stack`、`dev-port`、`start-tauri-dev` 全部通过。 + +### 待验证(本轮未确认,不作为结论) + +- 原生(真实 Runner/IPC)与真实 Provider 下的同一批时序未执行:本轮结论来自 deterministic surface 与 mock 故障注入。 +- `src-tauri/src/project/bootstrap.rs` 在 `npm install` 之前读取 `package-lock.json` 计算 `lockSha256`,安装后仍使用旧字节;疑似只影响审计准确性,未复现、未修改。 +- 同 PID 下的写入 advisory guard(`write_lock.rs` 的 `bypassed_same_process`)是否会放过并行写,尚未排除误报。