From a7fa8e103a797140325a4a3a89c84189fc45b601 Mon Sep 17 00:00:00 2001 From: Linghong Date: Mon, 14 Sep 2026 03:57:20 +0000 Subject: [PATCH 01/13] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=AD=96=E5=88=92?= =?UTF-8?q?=E4=BA=A7=E7=89=A9=E5=BD=92=E7=B1=BB=E4=B8=BA=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将策划产物注册 kind 从 design-document 调整为 document 同步更新项目工具测试夹具 --- apps/ai-game-creator-shell/src-tauri/src/assets.rs | 4 ++-- .../src-tauri/src/tests/project_tools.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index bbf431c0a..c4382bcef 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -647,9 +647,9 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result register_local_asset_at( root, &relative, - "design-document", + "document", media_type, - "design-document", + "document", GameCreationAppAssetSource { kind: GameCreationAppAssetSourceKind::Uploaded, canvas_project_id: None, 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 bd77f59e6..a37bc05cb 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 @@ -6197,7 +6197,7 @@ fn local_project_resource_previews_require_registered_safe_resources() { register_local_asset_at( &root, "game/design.md", - "design-document", + "document", "text/markdown", "generated", source(), From 991b2a1104ece46ebfda1ec44d4d399f173d023b Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 14 Sep 2026 13:19:13 +0800 Subject: [PATCH 02/13] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20AGC=20=E5=BC=82?= =?UTF-8?q?=E6=AD=A5=E6=93=8D=E4=BD=9C=E6=81=A2=E5=A4=8D=E9=97=AD=E7=8E=AF?= =?UTF-8?q?=20(#346)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 变更内容 AGC 登录、Runner 会话、最近项目检查和首页自动创建原先各自维护异步状态,响应体卡住、单目录变慢或切页会导致按钮长期 busy、项目列表整体不可用或重复创建项目。本 PR 将这些入口收口到可恢复的生命周期边界: - 认证响应体读取增加独立超时,refresh singleflight 在失败后释放; - Runner 会话安装/清除移到 blocking worker,登录/退出增加 45 秒 UI fence; - 最近项目逐项检查并设置单项目超时,已完成行不受其它慢目录阻塞; - 首页自动创建锁提升到 WorkspaceLauncher,切页后仍防重并保留状态; - 新增异步闭环技术方案、body 卡住测试、逐行项目测试和跨页创建测试。 ## 验证 - `npm --prefix apps/ai-game-creator-shell run typecheck` - `npm --prefix apps/ai-game-creator-shell exec -- vitest run tests/clientHttp.test.ts tests/clientApi.test.ts tests/recentProjectsModel.test.ts --reporter=dot` - `npm --prefix apps/ai-game-creator-shell exec -- vitest run tests/appSurface.test.ts --reporter=dot` - `cargo check --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml` - `npm run check:doc-index` - `npm run check:encoding` - `git diff --check` `appSurface.test.ts` 390/390 通过;保留仓库既有 act/jsdom media warning。本 PR 未触发真实 Provider 或发布安装包。 Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/346 Co-authored-by: kdletters Co-committed-by: kdletters --- .../src-tauri/src/commands.rs | 36 ++++++---- .../src/app/AuthenticatedClient.tsx | 19 ++++-- .../features/app-shell/WorkspaceLauncher.tsx | 1 + .../src/features/app-shell/model.ts | 53 ++++++++------- .../app-shell/useHomeProjectCreation.ts | 66 ++++++++++++------- .../features/app-shell/useRecentProjects.ts | 53 ++++++++++----- .../src/services/clientAuth.ts | 14 +++- .../src/services/clientHttp.ts | 54 +++++++++++++++ .../src/view/home/index.tsx | 8 ++- .../tests/appSurface/home.suite.ts | 23 +++++-- .../tests/clientApi.test.ts | 32 +++++++++ .../tests/clientHttp.test.ts | 24 +++++++ .../tests/recentProjectsModel.test.ts | 39 +++++++++++ docs/README.md | 1 + ...术方案】AGC异步操作可恢复闭环-2026-09-14.md | 44 +++++++++++++ 15 files changed, 370 insertions(+), 97 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/recentProjectsModel.test.ts create mode 100644 docs/【技术方案】AGC异步操作可恢复闭环-2026-09-14.md diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index fbfd893e2..c81dd2663 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1945,28 +1945,36 @@ pub(crate) fn read_platform_account_session_generation() -> u64 { } #[tauri::command] -pub(crate) fn install_platform_account_session( +pub(crate) async fn install_platform_account_session( user_id: String, access_token: String, api_base_url: String, generation: u64, ) -> Result<(), String> { - validate_platform_session_input(&user_id, &access_token, &api_base_url, generation)?; - install_external_agent_runner_platform_session( - &user_id, - &access_token, - &api_base_url, - generation, - )?; - install_platform_session(&user_id, &access_token, &api_base_url, generation) + tokio::task::spawn_blocking(move || { + validate_platform_session_input(&user_id, &access_token, &api_base_url, generation)?; + install_external_agent_runner_platform_session( + &user_id, + &access_token, + &api_base_url, + generation, + )?; + install_platform_session(&user_id, &access_token, &api_base_url, generation) + }) + .await + .map_err(|error| format!("安装本地运行时会话任务意外终止:{error}"))? } #[tauri::command] -pub(crate) fn clear_platform_account_session(generation: u64) -> Result<(), String> { - shutdown_game_creator_codex_app_servers()?; - clear_external_agent_runner_platform_session(generation)?; - clear_platform_session(generation); - Ok(()) +pub(crate) async fn clear_platform_account_session(generation: u64) -> Result<(), String> { + tokio::task::spawn_blocking(move || { + shutdown_game_creator_codex_app_servers()?; + clear_external_agent_runner_platform_session(generation)?; + clear_platform_session(generation); + Ok(()) + }) + .await + .map_err(|error| format!("清除本地运行时会话任务意外终止:{error}"))? } #[tauri::command] diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index 3878476a3..a91af7ff4 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -75,6 +75,7 @@ function withAuthCheckTimeout( timeoutMs: number, message: string, ) { + void promise.catch(() => undefined); let timeoutId: number | undefined; const timeout = new Promise((_, reject) => { timeoutId = window.setTimeout(() => reject(new Error(message)), timeoutMs); @@ -492,10 +493,14 @@ export function AuthenticatedClient({ password, loginApiBaseUrl, ); - const committedGeneration = await commitAuthenticatedPlatformSession( - user, - loginGeneration, - loginApiBaseUrl, + const committedGeneration = await withAuthCheckTimeout( + commitAuthenticatedPlatformSession( + user, + loginGeneration, + loginApiBaseUrl, + ), + AUTH_CHECK_RUNNER_TIMEOUT_MS, + '连接本地运行时超时,请重试或重启客户端', ); if (committedGeneration === null) { return; @@ -524,7 +529,11 @@ export function AuthenticatedClient({ clearStoredAuthAccessToken(); } try { - await clearCommittedPlatformSession(logoutGeneration); + await withAuthCheckTimeout( + clearCommittedPlatformSession(logoutGeneration), + AUTH_CHECK_RUNNER_TIMEOUT_MS, + '清理本地运行时超时,请重启客户端后再登录', + ); } catch (error) { nativeClearError = error; } 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 94b415df1..913ecac2f 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 @@ -522,6 +522,7 @@ export function WorkspaceLauncherShell({ onStatusChange={setStatus} recentProjectRows={recentProjectRows} onCreateDraftAutomatically={createHomeDraftAutomatically} + creationBusy={homeProject.projectAction === 'creating'} onProjectsOpen={() => setLauncherView('projects')} onProjectOpen={(path) => { setProjectPath(path); diff --git a/apps/ai-game-creator-shell/src/features/app-shell/model.ts b/apps/ai-game-creator-shell/src/features/app-shell/model.ts index 6e1fd8d1d..5c54a5055 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/model.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/model.ts @@ -230,6 +230,9 @@ export function buildRecentProjectRows( >, recentWorkspaceRefreshing: boolean, ): RecentProjectRow[] { + // The refresh flag is kept for the page-level indicator. Each row owns its + // pending state so a slow directory cannot disable already inspected rows. + void recentWorkspaceRefreshing; return recentWorkspaces.map((workspace) => { const directoryStatus = recentWorkspaceStatuses[workspace]; const isPendingStatus = directoryStatus === undefined; @@ -237,34 +240,31 @@ export function buildRecentProjectRows( directoryStatus?.projectName || workspace.split(/[\\/]/).filter(Boolean).pop() || workspace; - const status = recentWorkspaceRefreshing + const status = isPendingStatus ? '检查中' - : isPendingStatus - ? '检查中' - : directoryStatus === null - ? '检查失败' - : directoryStatus?.exists === false - ? '未找到' - : directoryStatus?.isDirectory === false - ? '不是文件夹' - : directoryStatus?.manifestError - ? '无法读取' - : (directoryStatus?.isGodotProject === true || - directoryStatus?.isCocosProject === true) && - directoryStatus?.isGameCreatorProject === false - ? '可导入' - : directoryStatus?.isGameCreatorProject === false - ? '未初始化' - : directoryStatus?.recentRunStatus - ? formatRecentProjectRunStatus( - directoryStatus.recentRunStatus, - directoryStatus.recentRunStopReason, - ) - : directoryStatus?.isGodotProject - ? '可打开' - : '本地项目'; + : directoryStatus === null + ? '检查失败' + : directoryStatus?.exists === false + ? '未找到' + : directoryStatus?.isDirectory === false + ? '不是文件夹' + : directoryStatus?.manifestError + ? '无法读取' + : (directoryStatus?.isGodotProject === true || + directoryStatus?.isCocosProject === true) && + directoryStatus?.isGameCreatorProject === false + ? '可导入' + : directoryStatus?.isGameCreatorProject === false + ? '未初始化' + : directoryStatus?.recentRunStatus + ? formatRecentProjectRunStatus( + directoryStatus.recentRunStatus, + directoryStatus.recentRunStopReason, + ) + : directoryStatus?.isGodotProject + ? '可打开' + : '本地项目'; const canReveal = - !recentWorkspaceRefreshing && Boolean(directoryStatus) && directoryStatus?.exists !== false && directoryStatus?.isDirectory !== false; @@ -286,7 +286,6 @@ export function buildRecentProjectRows( recentRunStopReason: directoryStatus?.recentRunStopReason ?? null, canReveal, canOpen: - !recentWorkspaceRefreshing && Boolean(directoryStatus) && directoryStatus?.exists !== false && directoryStatus?.isDirectory !== false && 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 dca3466fe..8ecc89fb7 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 @@ -644,37 +644,53 @@ export function useHomeProjectCreation({ startMode: ProjectStartMode, options: { suggestName: boolean }, ) { + if (projectActionRef.current) { + return '已有项目操作进行中,请稍候'; + } const invoke = resolveTauriInvoke(); if (!invoke) { throw new Error('需要在陶泥儿客户端内运行'); } - const suggestedName = options.suggestName - ? await suggestAutomaticProjectName(invoke, draft) - : null; - const result = await invoke( - 'create_automatic_local_game_project', - { - name: suggestedName, - planning: startMode === 'planning', - }, - ); + // This action is owned by WorkspaceLauncher rather than HomeView. The + // launcher survives navigation, so unmounting the home page cannot release + // the guard while project creation or first-turn import is still running. + projectActionRef.current = 'creating'; + setProjectAction('creating'); + setStatus('正在创建工作区'); try { - await enterCreatedHomeProject( - invoke, - result, - draft.creationType, - draft.prompt, - draft.attachments, - startMode, + const suggestedName = options.suggestName + ? await suggestAutomaticProjectName(invoke, draft) + : null; + const result = await invoke( + 'create_automatic_local_game_project', + { + name: suggestedName, + planning: startMode === 'planning', + }, ); - setStatus('已创建工作区,正在开始智能创作'); - return '已创建工作区并进入项目开发'; - } catch (error) { - const message = `工作区已创建;首条需求投递失败:${ - error instanceof Error ? error.message : String(error) - }`; - setStatus(message); - throw new Error(message); + try { + await enterCreatedHomeProject( + invoke, + result, + draft.creationType, + draft.prompt, + draft.attachments, + startMode, + ); + setStatus('已创建工作区,正在开始智能创作'); + return '已创建工作区并进入项目开发'; + } catch (error) { + const message = `工作区已创建;首条需求投递失败:${ + error instanceof Error ? error.message : String(error) + }`; + setStatus(message); + throw new Error(message); + } + } finally { + if (projectActionRef.current === 'creating') { + projectActionRef.current = null; + setProjectAction(null); + } } } 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 d930bb61d..2733aff91 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 @@ -19,6 +19,8 @@ import { writeRecentWorkspace, } from './model'; +const RECENT_WORKSPACE_CHECK_TIMEOUT_MS = 5_000; + export function useRecentProjects(setStatus: Dispatch>) { const [recentWorkspaces, setRecentWorkspaces] = useState(readRecentWorkspaces); @@ -34,14 +36,26 @@ export function useRecentProjects(setStatus: Dispatch>) { invoke: NonNullable>, workspace: string, ): Promise<[string, LocalProjectDirectoryStatus | null]> { + let timeoutHandle: number | undefined; try { - const result = await invoke( - 'inspect_local_project_directory', - { projectPath: workspace }, - ); + 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); + } } } @@ -53,18 +67,27 @@ export function useRecentProjects(setStatus: Dispatch>) { return; } let disposed = false; + let pendingCount = recentWorkspaces.length; + setRecentWorkspaceStatuses({}); setRecentWorkspaceRefreshing(true); - void Promise.all( - recentWorkspaces.map((workspace) => - inspectRecentWorkspace(invoke, workspace), - ), - ).then((entries) => { - if (disposed) { - return; - } - setRecentWorkspaceStatuses(Object.fromEntries(entries)); - setRecentWorkspaceRefreshing(false); - }); + + for (const workspace of recentWorkspaces) { + void inspectRecentWorkspace(invoke, workspace).then( + ([projectPath, status]) => { + if (disposed) { + return; + } + setRecentWorkspaceStatuses((current) => ({ + ...current, + [projectPath]: status, + })); + pendingCount -= 1; + if (pendingCount === 0) { + setRecentWorkspaceRefreshing(false); + } + }, + ); + } return () => { disposed = true; }; diff --git a/apps/ai-game-creator-shell/src/services/clientAuth.ts b/apps/ai-game-creator-shell/src/services/clientAuth.ts index c8c3494c4..a529ef10b 100644 --- a/apps/ai-game-creator-shell/src/services/clientAuth.ts +++ b/apps/ai-game-creator-shell/src/services/clientAuth.ts @@ -15,7 +15,11 @@ import { API_RESPONSE_ENVELOPE_VERSION, unwrapApiResponse, } from '../../../../packages/shared/src/http'; -import { fetchClientHttp, getClientServerBaseUrl } from './clientHttp'; +import { + fetchClientHttp, + getClientServerBaseUrl, + readClientHttpResponseText, +} from './clientHttp'; const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1'; @@ -104,7 +108,9 @@ export function getClientAuthErrorMessage(error: unknown, fallback: string) { } async function readAuthErrorMessage(response: Response, fallback: string) { - const text = await response.text(); + const text = await readClientHttpResponseText(response, { + url: 'auth error response', + }); if (!text.trim()) { return fallback; } @@ -158,7 +164,9 @@ async function requestAuthJson( { status: response.status }, ); } - const text = await response.text(); + const text = await readClientHttpResponseText(response, { + url, + }); return text ? unwrapApiResponse(JSON.parse(text) as T) : (null as T); } diff --git a/apps/ai-game-creator-shell/src/services/clientHttp.ts b/apps/ai-game-creator-shell/src/services/clientHttp.ts index 34a37c6e6..83196ea26 100644 --- a/apps/ai-game-creator-shell/src/services/clientHttp.ts +++ b/apps/ai-game-creator-shell/src/services/clientHttp.ts @@ -31,6 +31,60 @@ export function isClientHttpTimeoutError( return error instanceof ClientHttpTimeoutError; } +/** + * Read a response body with the same bounded lifetime as the request that + * produced it. Some transports resolve fetch() after headers arrive while + * leaving body consumption pending indefinitely. + */ +export async function readClientHttpResponseText( + response: Response, + options: { timeoutMs?: number | null; url?: string } = {}, +) { + const timeoutMs = + options.timeoutMs === undefined + ? CLIENT_HTTP_DEFAULT_TIMEOUT_MS + : options.timeoutMs; + if (timeoutMs === null) { + return response.text(); + } + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new RangeError('响应体超时时间必须是大于 0 的有限数值'); + } + + let timedOut = false; + let timeoutHandle: ReturnType | undefined; + const bodyPromise = response.text(); + // A transport may reject after cancel() unblocks the stream. The race owns + // the observable result, so keep the late rejection out of the global queue. + void bodyPromise.catch(() => undefined); + const timeout = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => { + timedOut = true; + try { + void response.body?.cancel().catch(() => undefined); + } catch { + // Response doubles and older WebViews may not expose cancel(). + } + reject( + new ClientHttpTimeoutError(options.url ?? 'response body', timeoutMs), + ); + }, timeoutMs); + }); + try { + return await Promise.race([bodyPromise, timeout]); + } catch (error) { + if (timedOut) { + throw new ClientHttpTimeoutError( + options.url ?? 'response body', + timeoutMs, + ); + } + throw error; + } finally { + if (timeoutHandle !== undefined) clearTimeout(timeoutHandle); + } +} + export type ClientServerPreset = 'release' | 'dev' | 'custom'; export type ClientServerSelection = { 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 e037e01e5..6e6a82824 100644 --- a/apps/ai-game-creator-shell/src/view/home/index.tsx +++ b/apps/ai-game-creator-shell/src/view/home/index.tsx @@ -112,6 +112,7 @@ type HomeViewProps = { draft: HomeDraft, startMode: ProjectStartMode, ) => Promise; + creationBusy?: boolean; onProjectsOpen: () => void; onProjectOpen: (path: string) => void; onProjectPick: () => void; @@ -123,6 +124,7 @@ export default function HomeView({ onStatusChange, recentProjectRows, onCreateDraftAutomatically, + creationBusy = false, onProjectsOpen, onProjectOpen, onProjectPick, @@ -148,7 +150,7 @@ export default function HomeView({ homeCreationType === 'doc' ? 'planning' : 'direct-build'; async function createFromHome() { - if (homeCreationBusyRef.current) { + if (homeCreationBusyRef.current || creationBusy) { return; } const referencedAttachments = richTextToAttachments(homeRichText); @@ -261,7 +263,7 @@ export default function HomeView({
+

+ ) : 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`)是否会放过并行写,尚未排除误报。 From 4e553bb15d095b7bd89c6b96c81e4bed835c8002 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 14 Sep 2026 15:41:17 +0800 Subject: [PATCH 08/13] =?UTF-8?q?=E6=94=B6=E7=AA=84=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E5=86=99=E9=94=81=E5=90=8C=E8=BF=9B=E7=A8=8B=E5=A4=8D=E7=94=A8?= =?UTF-8?q?=E5=88=A4=E6=8D=AE=E4=B8=BA=E5=90=8C=E7=BA=BF=E7=A8=8B=E9=87=8D?= =?UTF-8?q?=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 write_lock.rs:advisory 复用判据从「本进程持锁」收窄为「同一线程重入或自主流水线」,新增按锁路径登记真实持锁线程的 PROJECT_WRITE_LOCK_THREAD_OWNERS,登记在 create_new 成功处、在 guard Drop 里按路径注销(guard 会被移到别的线程再 Drop) - 恢复本进程其它线程写通道的串行化:一致快照读、project.diff / action_history、command.output_read、steer 序号分配、项目 revision 侧车、pending sidecar 复核与恢复安装重新等待并保持终态占用 - 调整 tests/project_tools.rs 的 agent_runtime_file_write_lock_failure_redacts_project_path:改为在另一条线程持锁,保持「别的写者持锁时 file.write 失败关闭且脱敏」的断言语义 - 调整 ui_editor/persistence.rs 的 recovery_install_respects_the_project_write_lock:同样改为在另一条线程持锁,保持占用失败断言 - 同步技术方案「2026-09-14 项目客户端占用锁收敛」段落,以及里程碑和实施计划的目标、验收标准、修改顺序、验证命令与未决事项 - 在 decision-log 记录复用判据收窄为同线程重入,并在 pitfalls 记录「同进程复用判据不能只看 pid」的现场、原因、处理与易错点 - 回答 AGC 生命周期文档「同 PID advisory guard 是否放过并行写」的未决项:确认会放过并行写,已收窄 Co-authored-by: DotCraft <273930855+dotcraft-ai@users.noreply.github.com> --- .../src-tauri/src/project/write_lock.rs | 71 ++++++++++++++++--- .../src-tauri/src/tests/project_tools.rs | 26 ++++++- .../src-tauri/src/ui_editor/persistence.rs | 27 ++++++- ...施计划】项目客户端占用锁收敛-2026-09-14.md | 11 +-- ...里程碑】项目客户端占用锁收敛-2026-09-14.md | 10 +-- .../shared-memory/decision-log.md | 7 ++ docs/project-memory/shared-memory/pitfalls.md | 9 +++ ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- ...】AGC客户端稳定版生命周期大切换-2026-09-14.md | 2 +- 9 files changed, 138 insertions(+), 27 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs b/apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs index 9ed36a8a9..bdf5bd33d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs @@ -14,16 +14,59 @@ const PROJECT_WRITE_LOCK_UNWRITTEN_GRACE_SECONDS: u64 = 30; const PROJECT_WRITE_LOCK_PID_REUSE_TOLERANCE_SECONDS: u64 = 5; const PROJECT_WRITE_LOCK_MAX_BYTES: u64 = 4 * 1024; +/// 本进程内真正落盘持有项目写锁的线程登记表。 +/// +/// `.agent/project.lock` 的 `pid` 只能证明“锁由本进程的某条写通道持有”,它分不清 +/// 两种完全不同的局面: +/// - **同一条调用链再次取锁**:持锁方就是自己,必须放行,否则每次嵌套项目写入都要 +/// 白等一个等待预算再报“项目正在被其他写操作占用”; +/// - **本进程另一条写通道正在写**:项目 revision 侧车、steer 序号、一致快照读、 +/// pending sidecar 复核和恢复安装都靠这把锁串行化,必须照旧等待。 +/// +/// 复用判据因此不能停在 `pid`:只有**当前线程**就是真实持锁线程时才返回 advisory +/// guard,本进程其余争用继续走有界等待与终态占用。登记按路径进行、按路径注销: +/// guard 可能被移到别的线程再 Drop(例如写入路径把锁交给阻塞线程池的持有者), +/// 按线程注销会漏项,让后续的重入判断失真。 +static PROJECT_WRITE_LOCK_THREAD_OWNERS: std::sync::Mutex> = + std::sync::Mutex::new(Vec::new()); + +fn project_write_lock_thread_owners( +) -> std::sync::MutexGuard<'static, Vec<(PathBuf, std::thread::ThreadId)>> { + // 登记表只是复用判据的加速器:中毒时继续用内部值,不能让一次取锁失败升级成 + // 整个进程再也写不了项目。 + PROJECT_WRITE_LOCK_THREAD_OWNERS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +fn register_project_write_lock_thread_owner(path: &Path) { + let mut owners = project_write_lock_thread_owners(); + if owners.iter().any(|(owner, _)| owner == path) { + return; + } + owners.push((path.to_path_buf(), std::thread::current().id())); +} + +fn unregister_project_write_lock_thread_owner(path: &Path) { + project_write_lock_thread_owners().retain(|(owner, _)| owner != path); +} + +/// 当前线程是否就是这条锁路径上真实落盘的持有者(同线程重入)。 +fn project_write_lock_reentered_by_current_thread(path: &Path) -> bool { + let thread = std::thread::current().id(); + project_write_lock_thread_owners() + .iter() + .any(|(owner, owner_thread)| owner == path && *owner_thread == thread) +} + #[derive(Debug)] pub(crate) struct ProjectWriteLock { path: PathBuf, content: String, - /// In the free-form autonomous lane a single Runtime process may have - /// several specialist actions in flight at once. A file lock is still - /// useful across processes, but making same-process contenders fail turns - /// ordinary parallel work into a dead run (and can deadlock nested tool - /// calls). Such a contender receives an in-process/advisory guard instead - /// of deleting the real holder's lock on drop. + /// 两种“本进程持锁但不必自等”的争用会拿到 advisory guard:同一线程重入(同一条 + /// 调用链再次取锁)和自主游戏构建流水线(它有意让并行专家动作同时在飞)。这两种 + /// 情况下争用是进程内重叠而不是另一个客户端在改项目,返回的 guard 不拥有 + /// `.agent/project.lock`,Drop 时也不得删除真实持有者的锁。 bypassed_same_process: bool, } @@ -47,6 +90,7 @@ impl Drop for ProjectWriteLock { if self.bypassed_same_process { return; } + unregister_project_write_lock_thread_owner(&self.path); if fs::read_to_string(&self.path).is_ok_and(|content| content == self.content) { let _ = fs::remove_file(&self.path); } @@ -815,6 +859,7 @@ pub(crate) fn acquire_project_write_lock_failure( path.display() ))); } + register_project_write_lock_thread_owner(&path); return Ok(ProjectWriteLock { path, content: content.clone(), @@ -860,11 +905,15 @@ pub(crate) fn acquire_project_write_lock_failure( } } } - if project_write_lock_is_owned_by_current_process(&path) { - // A project lock is the client-use lock. Nested calls in - // the same client process must reuse that ownership instead - // of waiting on their own durable marker. Cross-process - // contenders still take the normal retryable path. + if project_write_lock_is_owned_by_current_process(&path) + && (crate::agent::autonomous_game_build_root_run_active_at(root) + || project_write_lock_reentered_by_current_thread(&path)) + { + // 持锁方就是本进程自己时必须区分重入与并发:同一条调用链(同一 + // 线程)再次取锁,以及自主流水线有意并行专家动作,返回 advisory + // guard、不自等、不动真实锁;本进程**其它线程**正在写则继续走 + // 有界等待,保住 revision 侧车、steer 序号、一致快照读与恢复安装 + // 的串行化。 return Ok(ProjectWriteLock { path, content: String::new(), 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 7e39fccbe..8709bc5b0 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 @@ -5818,8 +5818,27 @@ async fn agent_runtime_file_write_lock_failure_redacts_project_path() { }, ) .expect("allow direct file write"); - let lock = acquire_project_write_lock(&root, "persistent-writer") - .expect("acquire persistent project writer"); + // 持锁方必须是**另一条线程**:本用例验证的是“别的写通道正在写时 file.write 必须 + // 走满等待预算并失败关闭”,同一条调用链自持锁属于重入复用,不会失败。 + let holder_root = root.clone(); + let (release_sender, release_receiver) = mpsc::channel::<()>(); + let holder = std::thread::spawn(move || { + let lock = acquire_project_write_lock(&holder_root, "persistent-writer") + .expect("acquire persistent project writer"); + let _ = release_receiver.recv(); + drop(lock); + }); + let lock_path = root.join(PROJECT_WRITE_LOCK_PATH); + for _ in 0..400 { + if lock_path.is_file() { + break; + } + std::thread::sleep(Duration::from_millis(5)); + } + assert!( + lock_path.is_file(), + "persistent writer must hold the project write lock" + ); let observation = execute_game_creator_agent_runtime_tool_action( &root, @@ -5837,7 +5856,8 @@ async fn agent_runtime_file_write_lock_failure_redacts_project_path() { ) .await; - drop(lock); + let _ = release_sender.send(()); + holder.join().expect("join persistent project writer"); assert_eq!(observation.status, "failed"); assert!(!observation .summary diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs index 04b72c0b3..bc7d08d88 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs @@ -1214,8 +1214,28 @@ mod tests { .expect("resolve primary"); fs::write(&primary, b"{broken").expect("corrupt primary"); - let project_lock = acquire_project_write_lock(directory.path(), "test.concurrent-save") - .expect("hold project write lock"); + // 持锁方必须是**另一条线程**:本用例验证的是“另一个写者持锁时恢复安装必须失败 + // 关闭”,同一条调用链自持锁属于重入复用,不再产生占用失败。 + let holder_root = directory.path().to_path_buf(); + let (release_sender, release_receiver) = std::sync::mpsc::channel::<()>(); + let holder = std::thread::spawn(move || { + let lock = acquire_project_write_lock(&holder_root, "test.concurrent-save") + .expect("hold project write lock"); + let _ = release_receiver.recv(); + drop(lock); + }); + let lock_path = resolve_local_project_path(directory.path(), PROJECT_WRITE_LOCK_PATH) + .expect("resolve project write lock path"); + for _ in 0..400 { + if lock_path.is_file() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!( + lock_path.is_file(), + "concurrent writer must hold the project write lock" + ); let error = load_ui_design_state_at(LoadUiDesignStateInput { project_path: directory.path().to_string_lossy().into_owned(), expected_project_id: PROJECT_ID.to_string(), @@ -1224,7 +1244,8 @@ mod tests { .expect_err("recovery must not install while another writer holds the lock"); assert!(error.contains("项目正在被其他写操作占用")); assert!(read_ui_design_document_path(&primary).is_err()); - drop(project_lock); + let _ = release_sender.send(()); + holder.join().expect("join concurrent writer"); let recovered = load_ui_design_state_at(LoadUiDesignStateInput { project_path: directory.path().to_string_lossy().into_owned(), diff --git a/docs/project-memory/plans/【实施计划】项目客户端占用锁收敛-2026-09-14.md b/docs/project-memory/plans/【实施计划】项目客户端占用锁收敛-2026-09-14.md index f1ffb1b07..e579a6514 100644 --- a/docs/project-memory/plans/【实施计划】项目客户端占用锁收敛-2026-09-14.md +++ b/docs/project-memory/plans/【实施计划】项目客户端占用锁收敛-2026-09-14.md @@ -15,14 +15,16 @@ Milestone: `【里程碑】项目客户端占用锁收敛-2026-09-14.md` ## 修改顺序 1. 统一同进程嵌套调用的项目锁语义,禁止自等待。 -2. 盘点并迁移 Runner 的项目级 owner 文件到统一锁,保留诊断投影与跨 boot 恢复。 -3. 删除重复项目级锁路径及其专属调用,保留底层原子写和 Git 锁。 -4. 补齐同进程重入、跨进程占用、崩溃恢复和锁释放测试。 +2. 收窄复用判据:按 `pid` 放行会放过本进程其它线程的并行写,改为按“当前线程就是真实持锁线程”判定重入,并保住同进程跨线程的等待与终态占用。 +3. 盘点并迁移 Runner 的项目级 owner 文件到统一锁,保留诊断投影与跨 boot 恢复。 +4. 删除重复项目级锁路径及其专属调用,保留底层原子写和 Git 锁。 +5. 补齐同进程重入、同进程跨线程争用、跨进程占用、崩溃恢复和锁释放测试。 ## 验证命令 - `cargo fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check` -- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml project_write_lock_reuses_same_process_owner_and_releases_on_drop --no-default-features` +- `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --test-threads=1` +- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml project_write_lock --no-default-features` - Runner owner 与 response stream 相关定向测试 - `npm run check:encoding` - `git diff --check` @@ -31,4 +33,5 @@ Milestone: `【里程碑】项目客户端占用锁收敛-2026-09-14.md` - Runner 与 GUI 可能是不同进程;统一锁前必须验证同一客户端不会互相阻塞。 - 旧 `.agent/runtime/execution-owner.lock` 残留需要按 PID/启动身份安全回收,不能直接删除。 +- 复用判据按线程判定:出现同进程跨线程重入的现场时先按 `*_locked` 入口处置,不要把判据退回按 `pid` 一律放行(那会放过并行写,见里程碑「边界」末条)。 - 若跨 boot 恢复或 GUI/Runner 联动回归,回滚统一路径迁移,保留已验证的同进程重入修复。 diff --git a/docs/project-memory/plans/【里程碑】项目客户端占用锁收敛-2026-09-14.md b/docs/project-memory/plans/【里程碑】项目客户端占用锁收敛-2026-09-14.md index ed1c643a2..218b10182 100644 --- a/docs/project-memory/plans/【里程碑】项目客户端占用锁收敛-2026-09-14.md +++ b/docs/project-memory/plans/【里程碑】项目客户端占用锁收敛-2026-09-14.md @@ -7,22 +7,24 @@ Parent Spec: `docs/technical/【技术方案】AI游戏创作智能体App实施 ## 目标 -项目只保留一个面向客户端占用的项目级跨进程锁,防止多个客户端同时打开同一项目;同一客户端进程内的嵌套调用复用既有项目锁,不因自身持锁进入等待。 +项目只保留一个面向客户端占用的项目级跨进程锁,防止多个客户端同时打开同一项目;同一客户端进程内**同一条写调用链(同一线程)的嵌套调用**复用既有项目锁,不因自身持锁进入等待。 ## 边界 - 项目客户端占用锁与项目写入调用的职责统一,跨进程竞争仍返回占用语义。 - Agent DB、session lane、manifest 原子写和 Git 自身的底层一致性机制不在本里程碑删除范围内。 -- 不改变项目 revision、权限、幂等、恢复和数据格式合同。 +- 不改变项目 revision、权限、幂等、恢复和数据格式合同。**本进程其它线程的并发写入必须继续串行化**:按 `pid` 一律返回 advisory guard 会放过并行写,直接违反本边界(见验收标准第 2 条)。 ## 验收标准 -- 同一进程内嵌套取得项目锁立即返回 advisory guard,不等待、不删除真实持有者锁。 +- 同一线程(同一条写调用链)嵌套取得项目锁立即返回 advisory guard,不等待、不删除真实持有者锁。 +- 本进程另一条线程持锁(模拟“另一个写通道/另一个客户端”的既有用例形态)时仍保持等待与终态占用:项目 revision 侧车、steer 序号分配、一致快照读、pending sidecar 复核和恢复安装不得被复用判据放过。 - 不同进程持有项目锁时仍保持占用失败与残留回收判据。 - 客户端项目占用入口与 Runtime 写入入口不会各自维护第二个项目级锁文件。 - 锁释放后下一客户端可重新取得锁。 -- 定向 Rust 锁测试、`cargo fmt --check`、`npm run check:encoding`、`git diff --check` 通过。 +- 定向 Rust 锁测试、`cargo fmt --check`、`npm run check:encoding`、`git diff --check` 通过;锁语义变更必须跑 `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --test-threads=1` 全量,定向用例覆盖不到 `project_tools` / `command_runtime` / `parallel_actions` / `runtime_state` / `response_stream` / `direct_tool_bridge` / `ui_editor::persistence` 里的锁不变量。 ## 未决事项 - Runner 的 `execution-owner.lock` 如何迁移到统一客户端占用锁,需要补充跨进程启动、恢复和诊断测试后再落地。 +- 同进程**跨线程**重入(持锁调用链在 `await` / `spawn_blocking` 之后于其它线程再次取锁)仍会走有界等待,预算耗尽时报“项目正在被其他写操作占用”。发现这类现场时按 2026-08-27 的既有处置改用 `*_locked` 入口复用已有 guard(`project-memory/shared-memory/pitfalls.md`「持锁调用链二次取锁」),不放宽整条锁的串行化语义。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index ef516ae27..d197d7e06 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8637,3 +8637,10 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策:DirectProject app-server thread 改为 `sandbox="danger-full-access"`,turn 改为 `sandboxPolicy.type="dangerFullAccess"`,不再发送 `writableRoots` 或 workspace 网络开关,原生命令网络随完整 sandbox 开放;app-server 交互请求不再按 grant root 做白名单裁剪,直接项目会话统一接受文件变更、命令执行和权限请求。首页只读对话、AGC `agc_tools` 业务授权、Provider 凭据隔离、Runtime 审计和客户端受控文件工具合同继续保留。 - 提示词同步:DirectProject 不再把路径范围描述成 Codex 原生能力禁区,但仍禁止主动输出 Token、Cookie、auth.json、`.env` 和 Runtime 私有控制面。 - 验证:Rust 定向单测覆盖 `danger-full-access` / `dangerFullAccess`、无 `writableRoots`、外部 grant root 仍接受,以及 DirectHome 继续只读拒绝。 + +## 2026-09-14 项目写锁的同进程复用收窄为同线程重入 + +- 背景:`write_lock.rs` 的 advisory 复用判据曾放宽为「`.agent/project.lock` 的 `pid` 等于当前进程」,使本进程所有写通道都不再等待。`Project CI` 的 Rust 全量门禁因此出现 12 条失败:另一线程持锁时一致快照读 / `project.diff` / `action_history` / `command.output_read` / steer 不再等待,4 路并行直写撞项目 revision 侧车(`File exists (os error 17)`),8 线程并发 steer 拿到重复序号,`file.write` 锁失败脱敏与恢复安装的失败关闭变成成功。 +- 决策:复用判据收窄为**同一条写调用链(同一线程)重入**——按锁路径登记真实持锁线程,只有当前线程就是持锁线程时才返回 advisory guard;本进程其它线程的争用继续走有界等待与终态占用。自主游戏构建流水线的并行专家动作豁免保持不变;跨进程占用、残留回收、权限分类、等待预算和错误文案不变。 +- 边界:锁定这些不变量的既有用例(`project_tools` / `command_runtime` / `parallel_actions` / `runtime_state` / `response_stream` / `direct_tool_bridge` / `ui_editor::persistence`)不得为了让锁语义通过而改写;用「同线程自持锁」模拟「另一个写者」的两条用例改为**在另一条线程持锁**,断言语义不变。同进程跨线程重入(持锁链在 `await` / `spawn_blocking` 后于其它线程再取锁)仍会等满预算,出现现场时按 2026-08-27 的既有处置改用 `*_locked` 入口,不放宽判据。 +- 关联文档:[项目客户端占用锁收敛里程碑](../plans/【里程碑】项目客户端占用锁收敛-2026-09-14.md)、[踩坑记录](pitfalls.md)。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 4df507394..8e8eca7e1 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,14 @@ # 踩坑与排障记录 +## 2026-09-14 项目写锁的同进程复用判据不能只看 pid + +- **现象**:`master` 的 `Project CI / Native shell tests` 红在 `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --test-threads=1`,12 条用例失败(`2439 passed; 12 failed`)。断言分三类:① 另一线程持锁时快照读 / `project.diff` / `action_history` / `command.output_read` / steer 不再等待(`... must wait for the project consistency lock`);② 并发写不再串行化——4 路并行直写撞项目 revision 侧车报 `File exists (os error 17)`,8 线程并发 steer 拿到 `[1, 1, 1, 1, 1, 1, 1, 2]`;③ 别的写通道持锁时 `file.write` 与恢复安装必须失败关闭,实测变成 `ok` / 不再报占用。 +- **原因**:`project/write_lock.rs` 的 advisory 复用判据从「自主游戏构建流水线 + 本进程持锁」放宽成「本进程持锁」,而判据只比 `.agent/project.lock` JSON 里的 `pid`。`pid` 只能证明锁由本进程持有,分不清「同一条调用链再次取锁(必须放行,否则自己等自己)」和「本进程另一条写通道正在写(必须继续串行化)」;于是同进程其它线程的写通道也拿到 advisory guard。 +- **处理**:复用判据收窄到**同线程重入**。新增 `PROJECT_WRITE_LOCK_THREAD_OWNERS`(按锁路径登记真实持锁线程)与 `project_write_lock_reentered_by_current_thread`:登记在 `create_new` 成功处,注销在 guard `Drop` 里并且**按路径**注销(guard 会被移到别的线程再 Drop,例如写入路径交给阻塞线程池的持有者)。只有当前线程就是该路径的持锁线程(或自主游戏构建流水线)才返回 advisory guard;本进程其余争用继续走有界等待与终态占用。 +- **易错点**:① 用「同线程」近似重入后,靠**同线程自持锁 + 同线程调用**模拟「另一个写者」的用例会失去信号(`agent_runtime_file_write_lock_failure_redacts_project_path`、`recovery_install_respects_the_project_write_lock`):它们必须改成**在另一条线程持锁**,断言才有意义;② 不要用「同进程还有 guard 活着」当重入依据,那等于退回按 `pid` 放行;③ 同进程**跨线程**重入(持锁调用链在 `await` / `spawn_blocking` 之后于其它线程再次取锁)仍会等满预算并在耗尽时报占用,出现这类现场按 2026-08-27 的处置改用 `*_locked` 入口复用已有 guard,不要放宽判据。 +- **验证**:本地定向 36 条(`--test-threads=1`,过滤 `_after_project_lock` / `bridge_write_file` / `project_write_lock` 等):CI 那 12 条里 9 条转绿(覆盖 `project_tools` / `command_runtime` / `parallel_actions` / `runtime_state` / `response_stream` / `external_generation_state`),3 条在本机被 Windows 临时目录 owner/DACL 挡在 setup(与本次改动无关,见 2026-09-13 条);`project_write_lock_reuses_same_process_owner_and_releases_on_drop`(同线程重入)继续通过。`cargo fmt --check`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check` 全绿。 +- **关联**:`apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs`、`src/agent/runtime_actions/project_gates.rs`(有界等待预算)、`docs/project-memory/plans/【里程碑】项目客户端占用锁收敛-2026-09-14.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`「2026-09-14 项目客户端占用锁收敛」。 + ## 2026-09-14 UI 超时围栏只能放弃等待,不能放弃结果;排队闸门不能无限等 - **现象**:登录/建项在 UI 上"超时"后报错,用户重试仍然无效;界面停在原页面,而后端/Runner 其实已经接受了这次操作(登录后本机登录态已装好、项目目录已建好)。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 9a6daf398..7ba0d39da 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1372,5 +1372,5 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过 ## 2026-09-14 项目客户端占用锁收敛 -项目锁职责收敛为“客户端占用项目”这一事实:同一客户端进程内的嵌套项目写入调用复用已有项目锁并返回 advisory guard,不再等待自身持有的 `.agent/project.lock`;跨进程竞争继续沿用现有占用、残留回收和权限分类。Runner 的 `.agent/runtime/execution-owner.lock` 迁移到统一项目占用锁仍属于进行中的里程碑,完成前不改变其恢复诊断合同。 +项目锁职责收敛为“客户端占用项目”这一事实:跨进程竞争继续沿用现有占用、残留回收和权限分类。同进程复用的判据收窄到**同一条写调用链(同一线程)重入**——本线程已落盘持有该项目的 `.agent/project.lock` 时再次取锁,返回 advisory guard,不再等待自身持有的锁。本进程**其它线程**的写入通道仍走有界等待与终态占用:项目 revision 侧车、steer 序号分配、一致快照读、pending sidecar 复核和恢复安装都依赖这把锁把同进程的并发写入串行化,按 `pid` 一律放行会让它们静默竞态。自主游戏构建流水线沿用既有的并行专家动作豁免。Runner 的 `.agent/runtime/execution-owner.lock` 迁移到统一项目占用锁仍属于进行中的里程碑,完成前不改变其恢复诊断合同。 diff --git a/docs/【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md b/docs/【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md index f5abedfa0..ba403e9e9 100644 --- a/docs/【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md +++ b/docs/【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md @@ -104,4 +104,4 @@ - 原生(真实 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`)是否会放过并行写,尚未排除误报。 +- 同 PID 下的写入 advisory guard(`write_lock.rs` 的 `bypassed_same_process`)是否会放过并行写:**已确认会**。只比 `pid` 的豁免让同进程其它线程的写通道也跳过 `.agent/project.lock`,`Project CI` 的 Rust 全量门禁因此红了 12 条(4 路并行直写撞项目 revision 侧车报 `File exists`、8 线程并发 steer 序号重复、一致快照读 / pending 复核 / 恢复安装不再等待、写锁失败不再失败关闭)。已把复用判据收窄为**同线程重入**:本进程其它线程继续走有界等待与终态占用,详见 `docs/project-memory/shared-memory/pitfalls.md`「项目写锁的同进程复用判据不能只看 pid」与 `docs/project-memory/plans/【里程碑】项目客户端占用锁收敛-2026-09-14.md`。 From 20f109027a6bb4447b293104697ed044a8bcbb44 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 14 Sep 2026 16:13:11 +0800 Subject: [PATCH 09/13] =?UTF-8?q?=E6=8C=89=E9=97=A8=E7=A6=81=E7=BB=84?= =?UTF-8?q?=E6=8B=86=E5=88=86=E5=AE=A2=E6=88=B7=E7=AB=AF=20CI=EF=BC=8CAGC?= =?UTF-8?q?=20=E7=9A=84=20web=20/=20rust=20=E4=B8=A4=E6=AE=B5=E5=B9=B6?= =?UTF-8?q?=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原生壳门禁原本挤在同一个 job 里串行执行,跑一遍 18 分 37 秒,其中 AI 游戏创作 壳独占约 15 分钟(壳内 Rust 套件 2451 条用例串行 441 秒),而微信 / 移动 / 桌面 / H5 的全部门禁加起来不到 50 秒。长尾拖住短门禁,runner 也无法并行。 - scripts/check-native-shells.mjs 支持 `--groups=`(contract / shells / agc-web / agc-rust / release):每个步骤与静态断言归属且只归属一个分组,不带参数时仍按 原顺序串行跑全部分组,本地 `npm run check:native-shells` 语义不变。 - 顺带修掉 H5 HostBridge 调用链扫描在 Windows 上恒红的缺陷:collectFiles 返回 反斜杠路径而期望清单是 POSIX 写法,scannedFiles.has() 永远为假。新增 normalizeScannedFilePath 只统一分隔符(不能复用会去后缀的 normalizeModulePath), 在 Linux 上是恒等变换。 - package.json 增加 5 个分组脚本,并把 ai-game-creator-shell:check 拆成 :check:web 与 :check:rust;聚合脚本保持 web && rust && agent-run:smoke 同序。 agent-run smoke 会 spawn cargo,因此归入 agc-rust。 - .gitea/workflows/project-ci.yml 拆成 6 个 job:新增 native-shell-tests(contract + shells + release)、ai-game-creator-shell-web-tests、ai-game-creator-shell-rust-tests 三个门禁 job,与 backend-tests / frontend-tests / repository-checks 并列。 - scripts/project-ci-workflow.test.ts 增加 3 条结构测试:分组恰好被一个 job 调用且 与脚本内声明一致、CI 不再调用全量入口、AGC web/rust 拆分与聚合脚本等价、独立 crate 预热必须发生在 AGC Rust 门禁之前。 - 同步运维文档、development-workflow、decision-log、pitfalls。 验证:`--groups=contract` 本地通过;vitest 11 passed;eslint 与 prettier 通过; check:encoding 13329 文件通过;check:doc-index 103 份通过。shells / agc-web / agc-rust / release 分组只能由 Linux CI 执行(Windows 上 spawnSync npm.cmd 报 EINVAL,属既有平台限制,非本次引入)。分支保护需补两个新 required context: `Project CI / AI game creator shell web tests (pull_request)` 与 `Project CI / AI game creator shell Rust tests (pull_request)`。 Co-authored-by: DotCraft <273930855+dotcraft-ai@users.noreply.github.com> --- .gitea/workflows/project-ci.yml | 274 +++++++++++------ .../shared-memory/decision-log.md | 9 + .../shared-memory/development-workflow.md | 2 +- docs/project-memory/shared-memory/pitfalls.md | 17 ++ ...发运维】本地开发验证与生产运维-2026-05-15.md | 16 +- package.json | 11 +- scripts/check-native-shells.mjs | 289 ++++++++++++++---- scripts/project-ci-workflow.test.ts | 104 ++++++- 8 files changed, 554 insertions(+), 168 deletions(-) diff --git a/.gitea/workflows/project-ci.yml b/.gitea/workflows/project-ci.yml index d796f3d91..763857ddc 100644 --- a/.gitea/workflows/project-ci.yml +++ b/.gitea/workflows/project-ci.yml @@ -27,9 +27,18 @@ env: RUSTC_WRAPPER: '' CARGO_BUILD_RUSTC_WRAPPER: '' +# job 声明顺序就是 runner 领取顺序,因此把最长尾的客户端 Rust 门禁排在前面, +# 让它在最少的等待下占用并发槽位;其余 job 按时长递减排列。 +# +# 客户端(微信壳 / Expo 移动壳 / Tauri 桌面壳 / AI 游戏创作壳)门禁原先全部串在 +# `Native shell tests` 一个 job 里,实测 18 分 37 秒,其中 AI 游戏创作壳的串行 +# Rust 套件(2451 个用例,`--test-threads=1`)单独占 533 秒。现在按门禁组拆成 +# `Native shell tests`、`AI game creator shell web tests` 与 +# `AI game creator shell Rust tests` 三个 job,各自的命令与拆分前逐一对应。 jobs: - repository-checks: - name: Repository checks + # 该 job 最长:AI 游戏创作壳的共享 / 平台 crate 测试加串行壳测试。 + ai-game-creator-shell-rust-tests: + name: AI game creator shell Rust tests runs-on: genarrative-ci steps: - name: Checkout full history from Gitea @@ -41,76 +50,63 @@ jobs: - name: Validate preinstalled CI job image and sandbox run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh - - name: Resolve comparison base + - name: Install npm dependencies + run: bash scripts/ci-npm-ci-with-retry.sh + + - name: Prepare AI game creator shell Rust dependencies shell: bash run: | set -euo pipefail - base_ref="$(node -e ' - const fs = require("node:fs"); - const event = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf8")); - process.stdout.write(event.pull_request?.base?.sha ?? event.before ?? ""); - ')" - if [[ -n "${base_ref}" && ! "${base_ref}" =~ ^0+$ ]]; then - git cat-file -e "${base_ref}^{commit}" 2>/dev/null || { - echo "comparison base commit is unavailable: ${base_ref}" >&2 - exit 1 - } - else - base_ref="$(git merge-base HEAD origin/master 2>/dev/null || git rev-parse HEAD)" - fi - resolved_base_ref="$(git rev-parse --verify "${base_ref}^{commit}" 2>/dev/null || true)" - head_ref="$(git rev-parse HEAD)" - if [[ "${resolved_base_ref}" == "${head_ref}" ]]; then - resolved_base_ref="$(git rev-parse --verify HEAD^ 2>/dev/null || true)" - fi - if [[ -z "${resolved_base_ref}" ]]; then - echo 'comparison base must resolve to a commit distinct from HEAD.' >&2 - exit 1 - fi - base_ref="${resolved_base_ref}" - if [[ "${GITHUB_EVENT_NAME:-}" == 'pull_request' ]] \ - && ! git merge-base --is-ancestor "${base_ref}" HEAD; then - echo 'pull request head does not contain the latest base commit; update the branch and rerun CI.' >&2 - exit 1 - fi - echo "SPACETIME_SCHEMA_BASE_REF=${base_ref}" >> "${GITHUB_ENV}" + for manifest_path in \ + server-rs/Cargo.toml \ + apps/ai-game-creator-shell/src-tauri/Cargo.toml; do + for attempt in $(seq 1 5); do + if cargo fetch --locked \ + --target x86_64-unknown-linux-gnu \ + --manifest-path "${manifest_path}"; then + break + fi + if [[ "${attempt}" -eq 5 ]]; then + echo "Cargo dependency fetch failed after 5 attempts: ${manifest_path}" >&2 + exit 1 + fi + sleep $((attempt * 2)) + done + done - - name: Install npm dependencies - run: bash scripts/ci-npm-ci-with-retry.sh + - name: Prepare standalone Rust crate dependencies + shell: bash + run: | + set -euo pipefail + # agent-runtime-core / agent-runtime-orchestration 被 server-rs/Cargo.toml 的 + # exclude 排除,不参与上面的 workspace 锁文件,因此上面那次锁定 fetch 覆盖不到它们; + # 而 `npm run ai-game-creator-shell:check:rust` 会用 + # `cargo test --manifest-path` 单独跑这两个 crate。不在这里预热的话,这两条测试 + # 会在测试阶段自己 `Updating crates.io index`,crates.io 一抖动整条 job 就红 + # (见 #327 / PR #316 run 1950)。 + # 两个 crate 都没有提交 Cargo.lock,所以这里只能做不带锁标志的 fetch: + # 加锁标志会因为缺少锁文件直接失败。生成的 Cargo.lock 落在两个 crate 目录内, + # 已被各自的 .gitignore 忽略,只留在容器里;随后的测试阶段因此能用锁定版本 + # 解析,不再触碰 registry index。 + for manifest_path in \ + server-rs/crates/agent-runtime-core/Cargo.toml \ + server-rs/crates/agent-runtime-orchestration/Cargo.toml; do + for attempt in $(seq 1 5); do + if cargo fetch \ + --target x86_64-unknown-linux-gnu \ + --manifest-path "${manifest_path}"; then + break + fi + if [[ "${attempt}" -eq 5 ]]; then + echo "standalone crate dependency fetch failed after 5 attempts: ${manifest_path}" >&2 + exit 1 + fi + sleep $((attempt * 2)) + done + done - - name: Run repository checks - run: npm run check:repository-ci - - frontend-tests: - name: Frontend tests - runs-on: genarrative-ci - steps: - - name: Checkout source from Gitea - env: - GENARRATIVE_GITEA_FETCH_DEPTH: '1' - GENARRATIVE_GITEA_TOKEN: ${{ github.token }} - run: genarrative-gitea-checkout - - - name: Validate preinstalled CI job image and sandbox - run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh - - - name: Install npm dependencies - run: bash scripts/ci-npm-ci-with-retry.sh - - - name: Run frontend and script tests - run: npm run test - - - name: Run BgFilter worker smoke harness tests - run: npm run bgfilter-worker:smoke-test - - - name: Validate production health patrol behavior - run: npm run check:production-health-patrol - - - name: Validate production API release behavior - run: npm run check:production-api-release - - - name: Validate production API deploy behavior - run: npm run check:production-api-deploy + - name: Run AI game creator shell Rust gates + run: npm run check:native-shells:agc-rust backend-tests: name: Backend tests @@ -194,6 +190,8 @@ jobs: - name: Check SpacetimeDB module run: cargo check --locked -p spacetime-module --manifest-path server-rs/Cargo.toml + # 客户端的壳级与契约门禁:静态契约断言、H5 / 微信 / 移动 / 桌面壳运行时门禁, + # 以及依赖发布产物的构建 smoke。 native-shell-tests: name: Native shell tests runs-on: genarrative-ci @@ -215,7 +213,6 @@ jobs: run: | set -euo pipefail for manifest_path in \ - server-rs/Cargo.toml \ apps/desktop-shell/src-tauri/Cargo.toml \ apps/ai-game-creator-shell/src-tauri/Cargo.toml; do for attempt in $(seq 1 5); do @@ -232,39 +229,118 @@ jobs: done done - - name: Prepare standalone Rust crate dependencies - shell: bash - run: | - set -euo pipefail - # agent-runtime-core / agent-runtime-orchestration 被 server-rs/Cargo.toml 的 - # exclude 排除,不参与上面的 workspace 锁文件,因此上面那次锁定 fetch 覆盖不到它们; - # 而 check:native-shells 会经 agent-runtime-*:check 用 `cargo test --manifest-path` - # 单独跑这两个 crate。不在这里预热的话,这两条测试会在测试阶段自己 - # `Updating crates.io index`,crates.io 一抖动整条 native shell 作业就红 - # (见 #327 / PR #316 run 1950)。 - # 两个 crate 都没有提交 Cargo.lock,所以这里只能做不带锁标志的 fetch: - # 加锁标志会因为缺少锁文件直接失败。生成的 Cargo.lock 落在两个 crate 目录内, - # 已被各自的 .gitignore 忽略,只留在容器里;随后的测试阶段因此能用锁定版本 - # 解析,不再触碰 registry index。 - for manifest_path in \ - server-rs/crates/agent-runtime-core/Cargo.toml \ - server-rs/crates/agent-runtime-orchestration/Cargo.toml; do - for attempt in $(seq 1 5); do - if cargo fetch \ - --target x86_64-unknown-linux-gnu \ - --manifest-path "${manifest_path}"; then - break - fi - if [[ "${attempt}" -eq 5 ]]; then - echo "standalone crate dependency fetch failed after 5 attempts: ${manifest_path}" >&2 - exit 1 - fi - sleep $((attempt * 2)) - done - done + - name: Run native shell contract gates + run: npm run check:native-shells:contract - name: Run native shell gates - run: npm run check:native-shells + run: npm run check:native-shells:shells + + - name: Run native shell release build smoke + run: npm run check:native-shells:release - name: Ensure native lockfiles are unchanged run: git diff --exit-code -- apps/desktop-shell/src-tauri/Cargo.lock apps/ai-game-creator-shell/src-tauri/Cargo.lock + + frontend-tests: + name: Frontend tests + runs-on: genarrative-ci + steps: + - name: Checkout source from Gitea + env: + GENARRATIVE_GITEA_FETCH_DEPTH: '1' + GENARRATIVE_GITEA_TOKEN: ${{ github.token }} + run: genarrative-gitea-checkout + + - name: Validate preinstalled CI job image and sandbox + run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh + + - name: Install npm dependencies + run: bash scripts/ci-npm-ci-with-retry.sh + + - name: Run frontend and script tests + run: npm run test + + - name: Run BgFilter worker smoke harness tests + run: npm run bgfilter-worker:smoke-test + + - name: Validate production health patrol behavior + run: npm run check:production-health-patrol + + - name: Validate production API release behavior + run: npm run check:production-api-release + + - name: Validate production API deploy behavior + run: npm run check:production-api-deploy + + repository-checks: + name: Repository checks + runs-on: genarrative-ci + steps: + - name: Checkout full history from Gitea + env: + GENARRATIVE_GITEA_FETCH_DEPTH: '0' + GENARRATIVE_GITEA_TOKEN: ${{ github.token }} + run: genarrative-gitea-checkout + + - name: Validate preinstalled CI job image and sandbox + run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh + + - name: Resolve comparison base + shell: bash + run: | + set -euo pipefail + base_ref="$(node -e ' + const fs = require("node:fs"); + const event = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf8")); + process.stdout.write(event.pull_request?.base?.sha ?? event.before ?? ""); + ')" + if [[ -n "${base_ref}" && ! "${base_ref}" =~ ^0+$ ]]; then + git cat-file -e "${base_ref}^{commit}" 2>/dev/null || { + echo "comparison base commit is unavailable: ${base_ref}" >&2 + exit 1 + } + else + base_ref="$(git merge-base HEAD origin/master 2>/dev/null || git rev-parse HEAD)" + fi + resolved_base_ref="$(git rev-parse --verify "${base_ref}^{commit}" 2>/dev/null || true)" + head_ref="$(git rev-parse HEAD)" + if [[ "${resolved_base_ref}" == "${head_ref}" ]]; then + resolved_base_ref="$(git rev-parse --verify HEAD^ 2>/dev/null || true)" + fi + if [[ -z "${resolved_base_ref}" ]]; then + echo 'comparison base must resolve to a commit distinct from HEAD.' >&2 + exit 1 + fi + base_ref="${resolved_base_ref}" + if [[ "${GITHUB_EVENT_NAME:-}" == 'pull_request' ]] \ + && ! git merge-base --is-ancestor "${base_ref}" HEAD; then + echo 'pull request head does not contain the latest base commit; update the branch and rerun CI.' >&2 + exit 1 + fi + echo "SPACETIME_SCHEMA_BASE_REF=${base_ref}" >> "${GITHUB_ENV}" + + - name: Install npm dependencies + run: bash scripts/ci-npm-ci-with-retry.sh + + - name: Run repository checks + run: npm run check:repository-ci + + # 客户端的 AI 游戏创作壳前端门禁:typecheck、壳内测试与本地 provider agent-run smoke。 + ai-game-creator-shell-web-tests: + name: AI game creator shell web tests + runs-on: genarrative-ci + steps: + - name: Checkout full history from Gitea + env: + GENARRATIVE_GITEA_FETCH_DEPTH: '0' + GENARRATIVE_GITEA_TOKEN: ${{ github.token }} + run: genarrative-gitea-checkout + + - name: Validate preinstalled CI job image and sandbox + run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh + + - name: Install npm dependencies + run: bash scripts/ci-npm-ci-with-retry.sh + + - name: Run AI game creator shell web gates + run: npm run check:native-shells:agc-web diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index d197d7e06..7ed8870b7 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -3,6 +3,15 @@ > 用途:记录已经确认、会影响后续开发的长期技术/产品/协作决策。短期讨论不要写在这里。 > 当前口径:历史条目的旧路径、旧版本和已退役对象只用于追溯,不构成现行实现依据;如与当前代码或 `docs/README.md` 冲突,以当前代码和最新专题文档为准。 +## 2026-09-14 客户端 CI 按门禁组拆成三个 job,AGC 的 web / rust 两段并行 + +- 背景:`Project CI / Native shell tests` 把微信壳、Expo 移动壳、Tauri 桌面壳、H5 HostBridge 与 AI 游戏创作壳的全部门禁串在一个 job 里,实测 18 分 37 秒;同一次运行的 Repository / Frontend / Backend 分别只要 3 分 21 秒、4 分 16 秒、6 分 14 秒,其余三个 job 结束后客户端 job 还要再跑十几分钟。日志时间戳显示门禁段 932 秒里:AGC `ai-game-creator-shell:check` 占 654 秒(其中壳内 Rust 套件 2451 个用例 `--test-threads=1` 单跑 441.58 秒、编译 79 秒),AGC vitest 75 秒,两个发布构建 smoke 加落盘断言 230 秒,而 h5 / 微信 / 移动 / 桌面壳的全部运行时门禁加起来不到 50 秒。 +- 决策:`scripts/check-native-shells.mjs` 引入 `--groups=`,把门禁分成 `contract`(静态契约断言)、`shells`(H5 / 微信 / Expo / 桌面壳运行时门禁)、`agc-web`(AGC typecheck 与壳内测试)、`agc-rust`(共享 / 平台 crate 测试、AGC 串行壳测试、agent-run smoke)、`release`(AGC 与桌面壳发布构建 smoke、落盘产物断言)五组,每组暴露一个 `check:native-shells:` 根脚本;不带 `--groups=` 时仍然串行跑全部分组,本地 `npm run check:native-shells` 语义不变。CI 据此把原客户端 job 拆成 `Native shell tests`(contract + shells + release)、`AI game creator shell web tests`(agc-web)、`AI game creator shell Rust tests`(agc-rust)三个 job,并把最长的 AGC Rust job 声明在最前,使 runner 领取顺序与关键路径一致。 +- 命令等价:`npm run ai-game-creator-shell:check` 拆成 `:check:web`(typecheck + 壳内测试)与 `:check:rust`(agent-runtime 两个独立 crate + `platform-llm` + `shared-contracts` + AGC 壳串行测试),聚合脚本仍是 `web && rust && agent-run:smoke` 同序同命令,本地与文档入口不变。`agent-run:smoke` 会用 `src-tauri/Cargo.toml` spawn `cargo`,因此归入 `agc-rust` 分组,与 AGC 依赖预热同 job。 +- 影响范围:`.gitea/workflows/project-ci.yml`(六个 job)、`scripts/check-native-shells.mjs`、根 `package.json` 门禁脚本、`scripts/project-ci-workflow.test.ts`(校验分组清单、根脚本内容与 job 覆盖,防止新增分组时静默漏跑)、开发运维文档与开发流程记忆。门禁覆盖不变,只有执行位置改变;Gitea `master` 分支保护的 required context 是追加式的(旧四个继续上报,需补上两个新 AGC context)。 +- 验证方式:`npx vitest run scripts/project-ci-workflow.test.ts`(11 条);`node scripts/check-native-shells.mjs --groups=contract` 本地 0.6 秒通过;`--groups=` 未知组与空组都要报错关闭。实测耗时按拆分前同一 run 的日志时间戳折算:关键路径从 18 分 37 秒收敛到 AGC Rust job 的约 13 分钟量级(若 runner 并发槽位 ≥ 6,可压缩到约 10.5 分钟)。 +- 关联文档:[开发运维](../../【开发运维】本地开发验证与生产运维-2026-05-15.md)、[踩坑记录](pitfalls.md)。 + ## 2026-09-10 策划 Agent 迁移只复用生产基建 - 决策:待实施的生产迁移以自由协作策划原型为行为基线,仅复用 Provider、恢复、文件操作、审计和 UI 通信;不继承旧 Planning V2 的强制工具、问询轮数、GDD 内容校验和版本审批。保留五阶段与顾问态、当前阶段资源注入和产物存在性检查,系统阶段空必需清单不增加解析或登记功能。 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index 6409631f4..7a4141c50 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -74,4 +74,4 @@ SpacetimeDB 任务统一先读取 `.codex/skills/genarrative-spacetimedb/SKILL.m ## Gitea CI 依赖闭合 -`.gitea/workflows/project-ci.yml` 的 `Native shell tests` 在运行原生壳门禁前,必须使用 `cargo fetch --locked` 预取 `server-rs/Cargo.toml`、桌面壳和 AGC 壳三份依赖。Backend host workspace tests 使用 `cargo test --locked --workspace --exclude spacetime-module --no-fail-fast`,避免 `spacetime-module` 的 `spacetime-types` feature 统一污染普通领域 crate 的 host 测试;随后单独执行 `cargo test --locked -p spacetime-module --no-fail-fast`,由 `spacetime-module/src/active.rs` 在 host 测试构建期间提供仅测试期的 SpacetimeDB ABI 链接支持,使该 crate 的纯单元测试也纳入 Backend 门禁。`spacetime-module` 的 reducer / procedure 运行时行为仍必须通过真实 SpacetimeDB runtime/integration harness 验证,host 链接支持不得被当作运行时替身。Backend 另外执行 `cargo check --locked -p spacetime-module` 验证模块源码。AGC 壳检查还会运行 `platform-llm` 与 `shared-contracts` 的 server-rs workspace 测试,这些命令以及 AGC 壳测试必须带 `--locked`,避免在测试阶段重新解析 registry index;锁文件发生变化时应先更新受信任 CI 镜像缓存,再重跑门禁。 +`.gitea/workflows/project-ci.yml` 的客户端门禁拆成三个 job,每个 job 只预热自己会构建的那几份依赖:`AI game creator shell Rust tests` 用 `cargo fetch --locked` 预取 `server-rs/Cargo.toml` 与 AGC 壳 manifest(`agent-run` smoke 会用 `src-tauri/Cargo.toml` spawn `cargo`,因此必须同 job),`Native shell tests` 预取桌面壳与 AGC 壳 manifest,`AI game creator shell web tests` 不触碰 Cargo,不预热。两个被 `server-rs/Cargo.toml` 排除、且没有提交 `Cargo.lock` 的独立 crate(`agent-runtime-core`、`agent-runtime-orchestration`)只能在 `AI game creator shell Rust tests` 里用不带锁标志的 fetch。Backend host workspace tests 使用 `cargo test --locked --workspace --exclude spacetime-module --no-fail-fast`,避免 `spacetime-module` 的 `spacetime-types` feature 统一污染普通领域 crate 的 host 测试;随后单独执行 `cargo test --locked -p spacetime-module --no-fail-fast`,由 `spacetime-module/src/active.rs` 在 host 测试构建期间提供仅测试期的 SpacetimeDB ABI 链接支持,使该 crate 的纯单元测试也纳入 Backend 门禁。`spacetime-module` 的 reducer / procedure 运行时行为仍必须通过真实 SpacetimeDB runtime/integration harness 验证,host 链接支持不得被当作运行时替身。Backend 另外执行 `cargo check --locked -p spacetime-module` 验证模块源码。AGC 壳检查还会运行 `platform-llm` 与 `shared-contracts` 的 server-rs workspace 测试,这些命令以及 AGC 壳测试必须带 `--locked`,避免在测试阶段重新解析 registry index;锁文件发生变化时应先更新受信任 CI 镜像缓存,再重跑门禁。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 8e8eca7e1..24af67d1b 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,22 @@ # 踩坑与排障记录 +## 2026-09-14 客户端 CI 拆分后,选组运行会跳过未选分组,且必须同步分支保护 + +- **现象**:把 `Native shell tests` 拆成客户端三个 job 后,如果只跑 `npm run check:native-shells:release`,静态契约和壳运行时门禁都不会执行;如果只跑 `--groups=contract`,`desktop-release-binary-artifact` 又会因为缺少 `build/native/desktop/` 产物而失败。 +- **原因**:分组是执行范围,不是"额外检查"。`desktop-release-binary-artifact` 断言依赖同 job 内的 `desktop-shell-stage-release-binary` 步骤,所以它归 `release` 组,不能放进 `contract`;反过来,任何"只跑一组"的命令都不能被当成完整门禁。 +- **处理**:分组与 job 的对应关系固定为 `contract`+`shells`+`release` → `Native shell tests`,`agc-web` → `AI game creator shell web tests`,`agc-rust` → `AI game creator shell Rust tests`;`scripts/project-ci-workflow.test.ts` 校验"每个分组恰好被一个 job 调用一次"和"CI 不再调用全量 `npm run check:native-shells`",新增分组必须同步门禁脚本、根脚本与 workflow 三处。 +- **易错点**:① 拆 job 后 Gitea `master` 分支保护的 required context 要补齐两个新 AGC context,只改 workflow 不改分支保护会让新门禁在合并前不生效;② 每个 job 只预热自己会构建的 Cargo 依赖,`agent-run:smoke` 因为会 spawn `cargo` 必须留在 `agc-rust` 所在 job;③ 本地全量 `npm run check:native-shells` 仍会串行跑完所有分组,用它作为本地完整门禁,不要用单组脚本冒充。 +- **关联**:`.gitea/workflows/project-ci.yml`、`scripts/check-native-shells.mjs`、`scripts/project-ci-workflow.test.ts`、`.gitea` 分支保护设置。 + +## 2026-09-14 `check:native-shells` 的调用链扫描在 Windows 上恒假 + +- **现象**:Windows 本机运行 `npm run check:native-shells:contract` 时,`production-shell-dev-scaffold-scan` 报 `H5 HostBridge call chain scan is missing required files: src/ActiveApp.tsx, ...`,而仓库里这些文件都存在,Linux CI 从不报。 +- **原因**:`collectFiles` 在 Windows 上返回 `src\ActiveApp.tsx`,调用链扫描把该路径原样放进 `scannedFiles`,再与 POSIX 写法的期望清单(`h5HostBridgeRequiredCallChainFiles` 的 `src/ActiveApp.tsx`)比较,成员判断恒假。注意 `normalizeModulePath` 不能直接复用:它还会去掉 `.ts/.tsx` 后缀。 +- **处理**:新增 `normalizeScannedFilePath`(只统一分隔符、保留后缀)用于 `scannedFiles` 的登记;Linux 上 `split('/').join('/')` 是恒等变换,行为不变。 +- **验证**:`node scripts/check-native-shells.mjs --groups=contract` 在 Windows 上 0.6 秒通过(修复前同一条命令必红)。 +- **同一类限制(未改)**:Windows 本机跑 `shells` / `agc-web` / `agc-rust` / `release` 这些**带步骤**的分组会在第一条 npm 步骤直接失败:`spawnSync npm.cmd EINVAL`(Node 24 起不能不带 shell 直接执行 `.cmd`;而根 `npm run test` 另有 chmod/0600 语义的 Windows 专属失败)。因此 Windows 本机可用的只有 `--groups=contract`,完整门禁交给 Linux CI;不要为此把 `spawnSync` 改成 `shell: true`(步骤参数里含空格与中文字符串,会被 shell 重新解析)。 +- **关联**:`scripts/check-native-shells.mjs` 的 `collectH5HostBridgeCallChainFiles` / `normalizeScannedFilePath`。 + ## 2026-09-14 项目写锁的同进程复用判据不能只看 pid - **现象**:`master` 的 `Project CI / Native shell tests` 红在 `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --test-threads=1`,12 条用例失败(`2439 passed; 12 failed`)。断言分三类:① 另一线程持锁时快照读 / `project.diff` / `action_history` / `command.output_read` / steer 不再等待(`... must wait for the project consistency lock`);② 并发写不再串行化——4 路并行直写撞项目 revision 侧车报 `File exists (os error 17)`,8 线程并发 steer 拿到 `[1, 1, 1, 1, 1, 1, 1, 2]`;③ 别的写通道持锁时 `file.write` 与恢复安装必须失败关闭,实测变成 `ok` / 不再报占用。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 25f02695a..efd5c1704 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -262,16 +262,18 @@ npm run check ### Gitea Actions PR 门禁 -仓库级 Gitea Actions 工作流固定为 `.gitea/workflows/project-ci.yml`,在向 `master` 推送、创建或更新 PR,以及手工触发时运行。工作流拆成四个必须通过的 job: +仓库级 Gitea Actions 工作流固定为 `.gitea/workflows/project-ci.yml`,在向 `master` 推送、创建或更新 PR,以及手工触发时运行。工作流拆成六个必须通过的 job。job 声明顺序就是 runner 领取顺序,因此最长尾的 `AI game creator shell Rust tests` 排在最前:并发槽位不足时,它必须最先开始,wall clock 才由它而不是由排队决定。 所有 CI job 和 Jenkins Web Build 在根 workspace 安装前都必须确认 `npm --version` 为 `10.9.7`。Gitea job 使用预构建镜像内的固定版本;Jenkins Web Build 在每个独立 `bash -lc` 中 source `scripts/jenkins-prepare-npm-env.sh`,首次为 Jenkins 运行用户的版本隔离目录引导同版 npm,后续复用并把该 `bin` 放到 `PATH` 首位。旧固定镜像缺少版本元数据时只能报告 `npm_version=partial` 并由当前 job 的根 `npm ci` 继续校验 lock,不能把过渡状态当作工具链已闭合。 - `Repository checks`:调用唯一入口 `npm run check:repository-ci`,执行 `npm run lint`、AI 游戏创作壳 AppSurface 定向测试、主站与后台生产构建和提交差异空白检查。本地 master `pre-push` 复用同一入口,禁止在 workflow 与 hook 中维护两份近似命令。 - `Frontend tests`:按唯一根 workspace lockfile 执行一次干净的 `npm ci`,再独立执行根 `npm run test`、`npm run bgfilter-worker:smoke-test`、`npm run check:production-health-patrol`、`npm run check:production-api-release` 和 `npm run check:production-api-deploy`,让 Vitest、Node test smoke harness 及不依赖真实服务的生产巡检 / 发布 / 部署行为 fixture 在 Gitea job 中持续执行;其中 `.test.mjs` 使用 Node test runner,不依赖 Vitest 的 `scripts/**/*.test.ts` 收集规则。 - `Backend tests`:先对 `server-rs/Cargo.lock` 执行带 5 次整命令级有界重试的 `cargo fetch --locked`,再执行 `npm run check:server-rs-ddd`、`cargo test --locked --workspace --exclude spacetime-module --no-fail-fast`、`cargo test --locked -p spacetime-module --no-fail-fast`、`api-server --all-targets` 编译和 `cargo check --locked -p spacetime-module`;普通 workspace host 测试排除 `spacetime-module` 以避免其 `spacetime-types` feature 统一污染领域 crate,模块自身的纯单元测试通过独立 package test 纳入门禁。`spacetime-module` 的 reducer / procedure 运行时行为仍必须通过真实 SpacetimeDB runtime/integration harness 验证,不能把 host 链接支持当作运行时替身。依赖准备必须位于会触发 Cargo build 的 DDD / 产物边界门禁之前,避免锁新增依赖未命中镜像缓存时绕过既有下载重试。runner 安装 `ffmpeg`,避免视频抽帧测试因工具缺失提前返回。依赖真实服务或密钥的测试必须显式 `ignored`,不能让普通 PR job访问现场环境。 -- `Native shell tests`:按唯一根 workspace lockfile 安装全部 App 依赖后执行 `npm run check:native-shells`,对所有触发方式一致覆盖微信壳、Expo 和 Tauri 的完整验收,并执行 `npm run ai-game-creator-shell:check` 与 AI 游戏创作壳 release build smoke;最后确认桌面壳与 AI 游戏创作壳的 `Cargo.lock` 都没有被构建过程改写。共享 Agent Runtime 后台锁 suite 固定 `--test-threads=1`,不能用并行偶发失败后的逐项通过替代整套稳定门禁。 +- `Native shell tests`:按唯一根 workspace lockfile 安装全部 App 依赖后,用 `npm run check:native-shells:contract`、`npm run check:native-shells:shells` 和 `npm run check:native-shells:release` 分别执行静态契约、H5 / 微信 / Expo / Tauri 桌面壳运行时门禁,以及依赖发布产物的构建 smoke,最后确认桌面壳与 AI 游戏创作壳的 `Cargo.lock` 都没有被构建过程改写。 +- `AI game creator shell web tests`:执行 `npm run check:native-shells:agc-web`(即 `npm run ai-game-creator-shell:check:web`:AGC 壳 typecheck 与壳内测试)。该分组不触碰 Cargo,因此不预热 Rust 依赖。 +- `AI game creator shell Rust tests`:预热 `server-rs/Cargo.toml`、AGC 壳 manifest 与两个无锁独立 crate 后执行 `npm run check:native-shells:agc-rust`(即 `npm run ai-game-creator-shell:check:rust` 加 `npm run ai-game-creator-shell:agent-run:smoke`),覆盖共享 / 平台 crate 测试、AGC 壳串行 Rust 套件和本地 provider agent-run smoke。它会用 `src-tauri/Cargo.toml` spawn `cargo`,所以必须与 Rust 依赖预热同 job。共享 Agent Runtime 后台锁 suite 固定 `--test-threads=1`,不能用并行偶发失败后的逐项通过替代整套稳定门禁。 -四个 job 合起来覆盖根 `npm run check`,并补齐根检查没有包含的 BgFilter worker smoke harness、无密钥生产巡检 / 发布 / 部署行为 fixture、server-rs DDD、正式 workspace Rust 测试与现役后端编译门禁。普通 PR CI 不注入业务密钥,不启动真实 API、SpacetimeDB、OSS、支付、图片生成或生产 live smoke;需要现场环境、可变外部状态、Docker 编排或发布凭据的 `check:*` 继续按对应专题和 Jenkins 发布流程执行,不能遍历所有同名前缀脚本冒充 PR 门禁。 +六个 job 合起来覆盖根 `npm run check`,并补齐根检查没有包含的 BgFilter worker smoke harness、无密钥生产巡检 / 发布 / 部署行为 fixture、server-rs DDD、正式 workspace Rust 测试与现役后端编译门禁。客户端门禁的拆分口径是 `scripts/check-native-shells.mjs` 的 `--groups=`:五个分组(`contract`、`shells`、`agc-web`、`agc-rust`、`release`)各自对应一个 `check:native-shells:` 根脚本,并在 workflow 的某个 job 里被恰好调用一次;不带 `--groups=` 时脚本仍然串行跑全部分组,本地语义不变。`scripts/project-ci-workflow.test.ts` 会同时校验分组清单、根脚本内容与 job 覆盖,新增分组必须三处同步。普通 PR CI 不注入业务密钥,不启动真实 API、SpacetimeDB、OSS、支付、图片生成或生产 live smoke;需要现场环境、可变外部状态、Docker 编排或发布凭据的 `check:*` 继续按对应专题和 Jenkins 发布流程执行,不能遍历所有同名前缀脚本冒充 PR 门禁。 PR checkout 必须保留完整 Git 历史,并把 PR base SHA 传给 `SPACETIME_SCHEMA_BASE_REF`。`check:spacetime-schema` 依赖该基线识别已有表字段删除、改名、重排和改类型;事件给出的基线缺失或本地不可解析时必须直接失败,不能退化为空差异检查。Gitea 的 PR checkout 是 PR head,不是与目标分支的预合并 commit,因此 workflow 还会验证 PR head 包含事件中的最新 base commit;分支保护必须继续开启“PR 过期禁止合并”,过期分支先更新再重跑。向 `master` 直接推送时使用 push before SHA;手工触发先尝试 `origin/master`,若它与 `HEAD` 相同则改用 `HEAD^`,仍无法得到不同提交时失败关闭。 @@ -290,15 +292,15 @@ bash scripts/gitea-ci-job-image.sh export /仓库外受控路径/genarrative-git bash scripts/gitea-ci-job-image.sh load-runner ``` -执行账号只要有权访问宿主 Docker API 并管理 runner 容器即可,不强制使用 root;无该权限时由 runner 运维人员执行。更新顺序必须是 `build/verify -> export 仓库外镜像归档与 SHA-256 sidecar -> load-runner -> 确认无活跃 job -> 备份当前 config -> 增加或替换 label -> docker restart --timeout 660 gitea-runner`。`--timeout 660` 只是停止宽限,不是 drain API;rootless DinD supervisor 可能同时停止内层 dockerd,因此重启前必须确认 Gitea 没有 `in_progress` run 且内层 `docker ps` 为空。config 和镜像归档只保存到仓库外受控位置,不在文档、仓库或日志中记录注册信息。重启后先重跑真实 PR 的四个 job,复核隔离边界并确认全部通过,再清理旧镜像。回滚时先把 workflow 的 `runs-on` 改回 `ubuntu-latest`,再恢复 config 备份并重启 runner。 +执行账号只要有权访问宿主 Docker API 并管理 runner 容器即可,不强制使用 root;无该权限时由 runner 运维人员执行。更新顺序必须是 `build/verify -> export 仓库外镜像归档与 SHA-256 sidecar -> load-runner -> 确认无活跃 job -> 备份当前 config -> 增加或替换 label -> docker restart --timeout 660 gitea-runner`。`--timeout 660` 只是停止宽限,不是 drain API;rootless DinD supervisor 可能同时停止内层 dockerd,因此重启前必须确认 Gitea 没有 `in_progress` run 且内层 `docker ps` 为空。config 和镜像归档只保存到仓库外受控位置,不在文档、仓库或日志中记录注册信息。重启后先重跑真实 PR 的六个 job,复核隔离边界并确认全部通过,再清理旧镜像。回滚时先把 workflow 的 `runs-on` 改回 `ubuntu-latest`,再恢复 config 备份并重启 runner。 -四个 job 先运行镜像内 `genarrative-gitea-checkout`,再以 `GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1` 执行 `scripts/check-gitea-ci-job-image.sh`,校验 Node 与 npm 固定版本、仓库 Rust toolchain、受信任 PATH、四份缓存锁命中状态、原生命令、pkg-config 依赖、完整 bwrap sandbox 和 Chrome headless。运行时发现锁不匹配时必须输出对应 `*_cache_lock=partial` 和 Actions warning,提示可信分支落地后刷新镜像,不能把陈旧缓存误报为闭合。`RUSTUP_AUTO_INSTALL=0`,因此仓库 `rust-toolchain.toml` 变更必须先更新镜像,不能让 job 现场下载。每个 job 仍独立运行一次根 `npm ci`,以唯一 workspace lock 验证 PR 的全部 App 依赖;统一通过 `scripts/ci-npm-ci-with-retry.sh` 做最多 3 次整命令级有界重试,同时保留 `NPM_CONFIG_PREFER_OFFLINE=true` 和 npm 自身 10 次 fetch retry。命中镜像 cache 时只做干净解包,lock 变化时允许补齐差量。不在镜像内烘入 `node_modules`,也不挂载跨 PR 可写缓存。任何 job 的 sandbox canary 失败都必须停止,不允许跳过。Cargo 通过受控 proxy 下载 lock 差量时继续关闭 HTTP multiplexing,并设置 `CARGO_NET_RETRY=10`。 +六个 job 先运行镜像内 `genarrative-gitea-checkout`,再以 `GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1` 执行 `scripts/check-gitea-ci-job-image.sh`,校验 Node 与 npm 固定版本、仓库 Rust toolchain、受信任 PATH、四份缓存锁命中状态、原生命令、pkg-config 依赖、完整 bwrap sandbox 和 Chrome headless。运行时发现锁不匹配时必须输出对应 `*_cache_lock=partial` 和 Actions warning,提示可信分支落地后刷新镜像,不能把陈旧缓存误报为闭合。`RUSTUP_AUTO_INSTALL=0`,因此仓库 `rust-toolchain.toml` 变更必须先更新镜像,不能让 job 现场下载。每个 job 仍独立运行一次根 `npm ci`,以唯一 workspace lock 验证 PR 的全部 App 依赖;统一通过 `scripts/ci-npm-ci-with-retry.sh` 做最多 3 次整命令级有界重试,同时保留 `NPM_CONFIG_PREFER_OFFLINE=true` 和 npm 自身 10 次 fetch retry。命中镜像 cache 时只做干净解包,lock 变化时允许补齐差量。不在镜像内烘入 `node_modules`,也不挂载跨 PR 可写缓存。任何 job 的 sandbox canary 失败都必须停止,不允许跳过。Cargo 通过受控 proxy 下载 lock 差量时继续关闭 HTTP multiplexing,并设置 `CARGO_NET_RETRY=10`。 站点 stack 仍由宿主受控目录管理,`.env`、runner 注册文件和数据库凭据不进入仓库。Compose 必须在 helper/container 内把该目录挂到与宿主相同的绝对路径再执行;挂载到不同路径会让相对 bind source 被 Docker daemon 解析到错误的宿主目录并启动空数据。升级或 runner 迁移前先停止 Gitea 写入,并把 Gitea 冷快照、数据库导出、compose/env 与 runner config/.runner 保存到仓库外受控备份位置。备份文件、绝对宿主配置和注册 token 不得提交 Git,也不在共享文档中记录具体路径或注册内容。 -workflow 首次成功运行后,在 Gitea `master` 分支保护中把 `Project CI / Repository checks (pull_request)`、`Project CI / Frontend tests (pull_request)`、`Project CI / Backend tests (pull_request)`、`Project CI / Native shell tests (pull_request)` 四个完整 context 都设为合并必需检查,并从最近一周已上报 context 表复核名称后再保存。不能只填裸 job 名,否则无法匹配 Gitea 实际上报的 ` / ()`。只提交 workflow 文件不会自动创建 runner,也不会自动修改分支保护;如果 Actions 长时间停留在等待状态,先到仓库或组织的 Actions runner 页面确认存在在线、带 `genarrative-ci` 标签的 runner,再检查精确 Image ID 是否已装入内层 Docker。 +workflow 首次成功运行后,在 Gitea `master` 分支保护中把 `Project CI / Repository checks (pull_request)`、`Project CI / Frontend tests (pull_request)`、`Project CI / Backend tests (pull_request)`、`Project CI / Native shell tests (pull_request)`、`Project CI / AI game creator shell web tests (pull_request)`、`Project CI / AI game creator shell Rust tests (pull_request)` 六个完整 context 都设为合并必需检查,并从最近一周已上报 context 表复核名称后再保存。客户端 CI 拆分的迁移是**追加式**的:旧四个 job 名继续上报,但 `Native shell tests` 的内容已收窄到壳级与发布构建门禁,因此新增的两个 AGC context 必须补进必需检查,否则 AGC 门禁在合并前不生效。不能只填裸 job 名,否则无法匹配 Gitea 实际上报的 ` / ()`。只提交 workflow 文件不会自动创建 runner,也不会自动修改分支保护;如果 Actions 长时间停留在等待状态,先到仓库或组织的 Actions runner 页面确认存在在线、带 `genarrative-ci` 标签的 runner,再检查精确 Image ID 是否已装入内层 Docker。 -master 日常交付必须禁止直接 push,只允许经 PR 在当前 head 的四个 required context 全绿后合并;本地 `pre-commit` 的 staged ESLint/Prettier 和 master `pre-push` 的 Repository checks parity 只用于提前发现问题,可被 `--no-verify` 绕过,不能充当服务端权威门禁。紧急直推白名单如需保留,应按人员和时限最小化,并要求执行同一 `npm run check:repository-ci ` 后回读 push CI。 +master 日常交付必须禁止直接 push,只允许经 PR 在当前 head 的六个 required context 全绿后合并;本地 `pre-commit` 的 staged ESLint/Prettier 和 master `pre-push` 的 Repository checks parity 只用于提前发现问题,可被 `--no-verify` 绕过,不能充当服务端权威门禁。紧急直推白名单如需保留,应按人员和时限最小化,并要求执行同一 `npm run check:repository-ci ` 后回读 push CI。 SpacetimeDB bindings: diff --git a/package.json b/package.json index 16c0836c3..6b150908b 100644 --- a/package.json +++ b/package.json @@ -197,8 +197,15 @@ "agent-runtime-core:check": "cargo test --manifest-path server-rs/crates/agent-runtime-core/Cargo.toml", "agent-runtime-orchestration:check": "cargo test --manifest-path server-rs/crates/agent-runtime-orchestration/Cargo.toml", "ai-game-creator-shell:typecheck": "npm --prefix apps/ai-game-creator-shell run typecheck", - "ai-game-creator-shell:check": "npm run ai-game-creator-shell:typecheck && npm run test -- apps/ai-game-creator-shell/tests && npm run agent-runtime-core:check && npm run agent-runtime-orchestration:check && cargo test --locked -p platform-llm --manifest-path server-rs/Cargo.toml && cargo test --locked -p shared-contracts --manifest-path server-rs/Cargo.toml game_creation_app && cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --test-threads=1 && npm run ai-game-creator-shell:agent-run:smoke", - "check:native-shells": "node scripts/check-native-shells.mjs" + "ai-game-creator-shell:check:web": "npm run ai-game-creator-shell:typecheck && npm run test -- apps/ai-game-creator-shell/tests", + "ai-game-creator-shell:check:rust": "npm run agent-runtime-core:check && npm run agent-runtime-orchestration:check && cargo test --locked -p platform-llm --manifest-path server-rs/Cargo.toml && cargo test --locked -p shared-contracts --manifest-path server-rs/Cargo.toml game_creation_app && cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --test-threads=1", + "ai-game-creator-shell:check": "npm run ai-game-creator-shell:check:web && npm run ai-game-creator-shell:check:rust && npm run ai-game-creator-shell:agent-run:smoke", + "check:native-shells": "node scripts/check-native-shells.mjs", + "check:native-shells:contract": "node scripts/check-native-shells.mjs --groups=contract", + "check:native-shells:shells": "node scripts/check-native-shells.mjs --groups=shells", + "check:native-shells:agc-web": "node scripts/check-native-shells.mjs --groups=agc-web", + "check:native-shells:agc-rust": "node scripts/check-native-shells.mjs --groups=agc-rust", + "check:native-shells:release": "node scripts/check-native-shells.mjs --groups=release" }, "dependencies": { "@genarrative/image-canvas-core": "0.1.0", diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index 9fd7a7416..816daff0f 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -79,6 +79,72 @@ const aiGameCreatorViteConfigSource = fs.readFileSync( 'utf8', ); +// 按门禁组运行:默认跑全部分组(本地语义不变),CI 用 `--groups=` 把互不依赖的 +// 分组拆成独立 job。每个步骤和静态断言必须属于且只属于一个分组,分组名同时是 +// 根 `check:native-shells:` 脚本和 workflow job 的拆分口径。 +// - contract:纯源码 / 契约 / 清单断言,不需要任何构建产物。 +// - shells:微信壳、Expo 移动壳、Tauri 桌面壳与 H5 HostBridge 的现役运行时门禁。 +// - agc-web:AI 游戏创作壳的前端门禁(typecheck 与壳内测试,不触碰 Cargo)。 +// - agc-rust:AI 游戏创作壳的 Rust 门禁(共享 / 平台 crate 测试、串行壳测试和 +// 会用 `src-tauri/Cargo.toml` spawn `cargo` 的 agent-run smoke)。 +// - release:发布构建 smoke 和依赖发布产物的落盘断言。 +const nativeShellGateGroups = [ + 'contract', + 'shells', + 'agc-web', + 'agc-rust', + 'release', +]; +const requestedNativeShellGroups = readRequestedNativeShellGroups( + process.argv.slice(2), +); + +function readRequestedNativeShellGroups(argv) { + const groupsFlag = argv.find((argument) => argument.startsWith('--groups=')); + if (groupsFlag === undefined) { + return nativeShellGateGroups; + } + + const requested = groupsFlag + .slice('--groups='.length) + .split(',') + .map((group) => group.trim()) + .filter(Boolean); + if (requested.length === 0) { + throw new Error( + `--groups requires at least one of: ${nativeShellGateGroups.join(', ')}`, + ); + } + + const unknownGroups = requested.filter( + (group) => !nativeShellGateGroups.includes(group), + ); + if (unknownGroups.length > 0) { + throw new Error( + `unknown native shell gate group(s): ${unknownGroups.join(', ')}; expected ${nativeShellGateGroups.join(', ')}`, + ); + } + + return nativeShellGateGroups.filter((group) => requested.includes(group)); +} + +function runsNativeShellGateGroup(group) { + if (!nativeShellGateGroups.includes(group)) { + throw new Error(`unknown native shell gate group: ${group}`); + } + + return requestedNativeShellGroups.includes(group); +} + +function runNativeShellGate(group, label, gate) { + if (!runsNativeShellGateGroup(group)) { + return; + } + + console.log(`[check:native-shells] ${label}`); + gate(); +} + const productionShellScanRoots = [ 'apps/mobile-shell', 'apps/desktop-shell', @@ -161,7 +227,11 @@ function assertRootNativeShellCheckScripts() { } } -assertRootNativeShellCheckScripts(); +runNativeShellGate( + 'contract', + 'root-native-shell-check-scripts', + assertRootNativeShellCheckScripts, +); function assertNativeShellDependencyVersionGuardrails() { for (const snippet of [ "const rootPackageLockPath = new URL('../../../package-lock.json', import.meta.url)", @@ -209,7 +279,11 @@ function assertNativeShellDependencyVersionGuardrails() { } } -assertNativeShellDependencyVersionGuardrails(); +runNativeShellGate( + 'contract', + 'native-shell-dependency-version-guardrails', + assertNativeShellDependencyVersionGuardrails, +); const h5HostBridgeCallChainWrapperFiles = [ 'src/hooks/useHostNavigationCanGoBack.ts', 'src/components/platform-entry/platformProfileHostClipboard.ts', @@ -2156,6 +2230,7 @@ const h5HostBridgeTests = [ const h5NativeAppRouteFlowTestSteps = h5NativeAppRouteFlowContracts.flatMap( (contract) => (contract.targetedTests ?? []).map((test) => ({ + group: 'shells', label: `h5-native-app-route-${contract.route}`, command: npmCommand, args: ['run', 'test', '--', test.filePath, '-t', test.name], @@ -2176,67 +2251,98 @@ const wechatShellTests = [ const steps = [ { + group: 'shells', label: 'h5-host-bridge-tests', command: npmCommand, args: ['run', 'test', '--', ...h5HostBridgeTests], }, ...h5NativeAppRouteFlowTestSteps, { + group: 'shells', label: 'wechat-shell-tests', command: npmCommand, args: ['run', 'test', '--', ...wechatShellTests], }, { + group: 'shells', label: 'mobile-shell-typecheck', command: npmCommand, args: ['run', 'mobile-shell:typecheck'], }, { + group: 'shells', label: 'mobile-shell-test', command: npmCommand, args: ['run', 'mobile-shell:test'], }, { + group: 'shells', label: 'mobile-shell-eas-build-config-smoke', command: npmCommand, args: ['run', 'mobile-shell:build-config'], }, { + group: 'shells', label: 'mobile-shell-expo-config-smoke', command: npmCommand, args: ['run', 'mobile-shell:config'], }, { + group: 'shells', label: 'mobile-shell-expo-export-smoke', command: npmCommand, args: ['run', 'mobile-shell:export'], }, { + group: 'shells', label: 'desktop-shell-test', command: npmCommand, args: ['run', 'desktop-shell:test'], }, { + group: 'shells', label: 'desktop-shell-typecheck', command: npmCommand, args: ['run', 'desktop-shell:typecheck'], }, + // AI 游戏创作壳原先一步串完 typecheck、壳内测试、共享 / 平台 crate 测试和 + // 串行壳测试,CI 因此只有一条 10 分钟以上的长尾。这里按同一组命令切成 + // web 与 rust 两段,顺序与 `npm run ai-game-creator-shell:check` 完全一致, + // 但允许 CI 并行执行;本地全量运行仍然是 web -> rust -> smoke 原顺序。 { - label: 'ai-game-creator-shell-check', + group: 'agc-web', + label: 'ai-game-creator-shell-check-web', command: npmCommand, - args: ['run', 'ai-game-creator-shell:check'], + args: ['run', 'ai-game-creator-shell:check:web'], }, { + group: 'agc-rust', + label: 'ai-game-creator-shell-check-rust', + command: npmCommand, + args: ['run', 'ai-game-creator-shell:check:rust'], + }, + // agent-run smoke 会用 `src-tauri/Cargo.toml` spawn `cargo`,因此与 Rust 段同组, + // 保证它落在已经预热 AGC Cargo 依赖的 job 里。 + { + group: 'agc-rust', + label: 'ai-game-creator-shell-agent-run-smoke', + command: npmCommand, + args: ['run', 'ai-game-creator-shell:agent-run:smoke'], + }, + { + group: 'release', label: 'ai-game-creator-shell-release-build-smoke', command: npmCommand, args: ['run', 'ai-game-creator-shell:build', '--', '--no-bundle'], }, { + group: 'release', label: 'desktop-shell-release-build-smoke', command: npmCommand, args: ['run', 'desktop-shell:build', '--', '--no-bundle'], }, { + group: 'release', label: 'desktop-shell-stage-release-binary', command: npmCommand, args: ['run', 'desktop-shell:stage-release-binary'], @@ -2664,6 +2770,12 @@ function normalizeModulePath(modulePath) { .replace(/\.(jsx?|tsx?)$/, ''); } +// 调用链扫描用 POSIX 相对路径做集合成员比较,但 `collectFiles` 在 Windows 上返回 +// 反斜杠路径。这里只统一分隔符、保留扩展名,Linux 上与原行为完全一致。 +function normalizeScannedFilePath(filePath) { + return filePath.split(path.sep).join('/'); +} + function importedModulePath(fromFile, specifier) { if (specifier === '@') { return '.'; @@ -2758,12 +2870,12 @@ function collectH5HostBridgeCallChainFiles() { importsScannedFacadeCapability || imports.some((specifier) => wrapperModules.has(specifier)) ) { - scannedFiles.add(file); + scannedFiles.add(normalizeScannedFilePath(file)); } } for (const wrapperFile of h5HostBridgeCallChainWrapperFiles) { - scannedFiles.add(wrapperFile); + scannedFiles.add(normalizeScannedFilePath(wrapperFile)); } const missingRequiredFiles = h5HostBridgeRequiredCallChainFiles.filter( @@ -5021,6 +5133,10 @@ function assertDesktopReleaseBinaryArtifact() { } for (const step of steps) { + if (!runsNativeShellGateGroup(step.group)) { + continue; + } + console.log(`[check:native-shells] ${step.label}`); const result = spawnSync(step.command, step.args, { cwd: process.cwd(), @@ -5046,71 +5162,142 @@ for (const step of steps) { } } -console.log('[check:native-shells] desktop-release-binary-artifact'); -assertDesktopReleaseBinaryArtifact(); +runNativeShellGate( + 'release', + 'desktop-release-binary-artifact', + assertDesktopReleaseBinaryArtifact, +); -console.log('[check:native-shells] host-bridge-layer-layout'); -assertHostBridgeLayerLayout(); +runNativeShellGate( + 'contract', + 'host-bridge-layer-layout', + assertHostBridgeLayerLayout, +); -console.log('[check:native-shells] native-shell-capability-plan'); -assertNativeShellCapabilityPlan(); +runNativeShellGate( + 'contract', + 'native-shell-capability-plan', + assertNativeShellCapabilityPlan, +); -console.log('[check:native-shells] external-url-protocol-parity'); -assertExternalUrlProtocolParity(); +runNativeShellGate( + 'contract', + 'external-url-protocol-parity', + assertExternalUrlProtocolParity, +); -console.log('[check:native-shells] wechat-mini-program-route-parity'); -assertWechatMiniProgramRouteParity(); +runNativeShellGate( + 'contract', + 'wechat-mini-program-route-parity', + assertWechatMiniProgramRouteParity, +); -console.log('[check:native-shells] wechat-mini-program-capability-flows'); -assertWechatMiniProgramCapabilityFlows(); +runNativeShellGate( + 'contract', + 'wechat-mini-program-capability-flows', + assertWechatMiniProgramCapabilityFlows, +); -console.log('[check:native-shells] expo-mobile-capability-flows'); -assertExpoMobileCapabilityFlows(); +runNativeShellGate( + 'contract', + 'expo-mobile-capability-flows', + assertExpoMobileCapabilityFlows, +); -console.log('[check:native-shells] tauri-desktop-capability-flows'); -assertTauriDesktopCapabilityFlows(); +runNativeShellGate( + 'contract', + 'tauri-desktop-capability-flows', + assertTauriDesktopCapabilityFlows, +); -console.log('[check:native-shells] h5-native-app-route-flows'); -assertH5NativeAppRouteFlows(); +runNativeShellGate( + 'contract', + 'h5-native-app-route-flows', + assertH5NativeAppRouteFlows, +); -console.log('[check:native-shells] wechat-payment-result-boundaries'); -assertWechatPaymentResultBoundaries(); +runNativeShellGate( + 'contract', + 'wechat-payment-result-boundaries', + assertWechatPaymentResultBoundaries, +); -console.log('[check:native-shells] wechat-auth-failure-boundaries'); -assertWechatAuthFailureBoundaries(); +runNativeShellGate( + 'contract', + 'wechat-auth-failure-boundaries', + assertWechatAuthFailureBoundaries, +); -console.log('[check:native-shells] wechat-web-view-page-event-boundaries'); -assertWechatWebViewPageEventBoundaries(); +runNativeShellGate( + 'contract', + 'wechat-web-view-page-event-boundaries', + assertWechatWebViewPageEventBoundaries, +); -console.log('[check:native-shells] wechat-share-grid-failure-boundaries'); -assertWechatShareGridFailureBoundaries(); +runNativeShellGate( + 'contract', + 'wechat-share-grid-failure-boundaries', + assertWechatShareGridFailureBoundaries, +); -console.log('[check:native-shells] desktop-navigation-event-boundaries'); -assertDesktopNavigationEventBoundaries(); +runNativeShellGate( + 'contract', + 'desktop-navigation-event-boundaries', + assertDesktopNavigationEventBoundaries, +); -console.log('[check:native-shells] h5-host-bridge-event-subscription-gates'); -assertH5HostBridgeEventSubscriptionGates(); +runNativeShellGate( + 'contract', + 'h5-host-bridge-event-subscription-gates', + assertH5HostBridgeEventSubscriptionGates, +); -console.log('[check:native-shells] h5-host-bridge-payload-boundaries'); -assertH5HostBridgePayloadBoundaries(); +runNativeShellGate( + 'contract', + 'h5-host-bridge-payload-boundaries', + assertH5HostBridgePayloadBoundaries, +); -console.log('[check:native-shells] h5-native-app-transport-timeout-boundaries'); -assertH5NativeAppTransportTimeoutBoundaries(); +runNativeShellGate( + 'contract', + 'h5-native-app-transport-timeout-boundaries', + assertH5NativeAppTransportTimeoutBoundaries, +); -console.log('[check:native-shells] h5-native-app-message-source-boundaries'); -assertH5NativeAppMessageSourceBoundaries(); +runNativeShellGate( + 'contract', + 'h5-native-app-message-source-boundaries', + assertH5NativeAppMessageSourceBoundaries, +); -console.log('[check:native-shells] h5-native-app-transport-facade-boundary'); -assertH5NativeAppTransportFacadeBoundary(); +runNativeShellGate( + 'contract', + 'h5-native-app-transport-facade-boundary', + assertH5NativeAppTransportFacadeBoundary, +); -console.log('[check:native-shells] generated-native-shell-artifact-boundary'); -assertNoTrackedGeneratedNativeShellArtifacts(); -assertGeneratedNativeShellArtifactsAreIgnored(); +runNativeShellGate( + 'contract', + 'generated-native-shell-artifact-boundary', + () => { + assertNoTrackedGeneratedNativeShellArtifacts(); + assertGeneratedNativeShellArtifactsAreIgnored(); + }, +); -console.log('[check:native-shells] ai-game-creator-shell-user-dev-boundary'); -assertAiGameCreatorShellUserDevBoundary(); +runNativeShellGate( + 'contract', + 'ai-game-creator-shell-user-dev-boundary', + assertAiGameCreatorShellUserDevBoundary, +); -console.log('[check:native-shells] production-shell-dev-scaffold-scan'); -assertNoProductionShellDevScaffoldTerms(); +runNativeShellGate( + 'contract', + 'production-shell-dev-scaffold-scan', + assertNoProductionShellDevScaffoldTerms, +); +console.log( + `[check:native-shells] groups=${requestedNativeShellGroups.join(',')}`, +); console.log('[check:native-shells] OK'); diff --git a/scripts/project-ci-workflow.test.ts b/scripts/project-ci-workflow.test.ts index 03eee48c6..c3b8073dd 100644 --- a/scripts/project-ci-workflow.test.ts +++ b/scripts/project-ci-workflow.test.ts @@ -40,8 +40,28 @@ const jobNames = [ 'frontend-tests', 'backend-tests', 'native-shell-tests', + 'ai-game-creator-shell-web-tests', + 'ai-game-creator-shell-rust-tests', ] as const; +const rootPackageJson = JSON.parse( + readFileSync(resolve(process.cwd(), 'package.json'), 'utf8'), +) as { scripts?: Record }; +const nativeShellGateScript = readFileSync( + resolve(process.cwd(), 'scripts/check-native-shells.mjs'), + 'utf8', +); + +// 客户端门禁拆分口径:门禁脚本里的每个分组都由一个根 npm 脚本暴露,并在 workflow +// 的某个 job 里被恰好调用一次。新增分组时必须同步这三处,否则拆分就会静默漏跑门禁。 +const nativeShellGateGroupScripts = { + contract: 'npm run check:native-shells:contract', + shells: 'npm run check:native-shells:shells', + 'agc-web': 'npm run check:native-shells:agc-web', + 'agc-rust': 'npm run check:native-shells:agc-rust', + release: 'npm run check:native-shells:release', +} as const; + function jobSection(jobName: (typeof jobNames)[number]) { const jobStart = workflow.indexOf(` ${jobName}:`); expect(jobStart).toBeGreaterThanOrEqual(0); @@ -67,6 +87,10 @@ function stepSection(jobName: (typeof jobNames)[number], stepName: string) { ); } +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); +} + function backendStepIndex(stepName: string) { const backendJobStart = workflow.indexOf(' backend-tests:'); const nativeShellJobStart = workflow.indexOf(' native-shell-tests:'); @@ -87,7 +111,9 @@ describe('project CI workflow', () => { }); it('keeps every job on the isolated preinstalled CI image boundary', () => { - expect(workflow.match(/^ {4}runs-on: genarrative-ci$/gm)).toHaveLength(4); + expect(workflow.match(/^ {4}runs-on: genarrative-ci$/gm)).toHaveLength( + jobNames.length, + ); expect(workflow).not.toContain('actions/checkout'); expect(workflow).not.toContain('actions/setup-node'); expect(workflow).not.toMatch(/^\s+run: .*\b(?:apt|rustup)\b/m); @@ -314,17 +340,77 @@ describe('project CI workflow', () => { expect(frontendJob).toContain('run: npm run check:production-api-deploy'); const nativeJob = jobSection('native-shell-tests'); - expect(nativeJob).toContain('run: npm run check:native-shells'); + expect(nativeJob).toContain('run: npm run check:native-shells:contract'); + expect(nativeJob).toContain('run: npm run check:native-shells:shells'); + expect(nativeJob).toContain('run: npm run check:native-shells:release'); expect(nativeJob).toContain( 'git diff --exit-code -- apps/desktop-shell/src-tauri/Cargo.lock apps/ai-game-creator-shell/src-tauri/Cargo.lock', ); - expect(nativeJob).toContain('server-rs/Cargo.toml'); + expect(nativeJob).toContain('apps/desktop-shell/src-tauri/Cargo.toml'); + expect(nativeJob).toContain( + 'apps/ai-game-creator-shell/src-tauri/Cargo.toml', + ); expect(nativeJob).toContain('cargo fetch --locked'); }); - it('prefetches the excluded standalone Rust crates before the native shell gates', () => { + it('runs every native shell gate group exactly once across the split jobs', () => { + for (const [group, script] of Object.entries(nativeShellGateGroupScripts)) { + expect(rootPackageJson.scripts?.[`check:native-shells:${group}`]).toBe( + `node scripts/check-native-shells.mjs --groups=${group}`, + ); + expect( + workflow.match(new RegExp(`^ {8}run: ${escapeRegExp(script)}$`, 'mu')), + ).toHaveLength(1); + } + + const declaredGroups = [ + ...nativeShellGateScript + .slice( + nativeShellGateScript.indexOf('const nativeShellGateGroups = ['), + nativeShellGateScript.indexOf( + '];', + nativeShellGateScript.indexOf('const nativeShellGateGroups = ['), + ), + ) + .matchAll(/'([a-z-]+)'/gu), + ].map((match) => match[1]); + expect(declaredGroups).toEqual(Object.keys(nativeShellGateGroupScripts)); + + // 全量分组脚本只允许本地使用:CI 必须走拆分后的分组脚本,避免整套门禁再被 + // 串行跑一遍。 + expect(workflow).not.toMatch(/^ {8}run: npm run check:native-shells$/mu); + }); + + it('splits the AI game creator shell gates into web and Rust jobs', () => { + const webJob = jobSection('ai-game-creator-shell-web-tests'); + expect(webJob).toContain('run: npm run check:native-shells:agc-web'); + expect(webJob).not.toContain('cargo fetch'); + + const rustJob = jobSection('ai-game-creator-shell-rust-tests'); + expect(rustJob).toContain('run: npm run check:native-shells:agc-rust'); + expect(rustJob).toContain('server-rs/Cargo.toml'); + expect(rustJob).toContain( + 'apps/ai-game-creator-shell/src-tauri/Cargo.toml', + ); + expect(rustJob).toContain('cargo fetch --locked'); + + // 拆开的 web / rust 两段必须还是原 `ai-game-creator-shell:check` 的同一条命令序列。 + expect(rootPackageJson.scripts?.['ai-game-creator-shell:check']).toBe( + 'npm run ai-game-creator-shell:check:web && npm run ai-game-creator-shell:check:rust && npm run ai-game-creator-shell:agent-run:smoke', + ); + expect(rootPackageJson.scripts?.['ai-game-creator-shell:check:web']).toBe( + 'npm run ai-game-creator-shell:typecheck && npm run test -- apps/ai-game-creator-shell/tests', + ); + expect( + rootPackageJson.scripts?.['ai-game-creator-shell:check:rust'], + ).toContain( + 'cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --test-threads=1', + ); + }); + + it('prefetches the excluded standalone Rust crates before the AI game creator shell Rust gates', () => { const standaloneStep = stepSection( - 'native-shell-tests', + 'ai-game-creator-shell-rust-tests', 'Prepare standalone Rust crate dependencies', ); for (const manifest of [ @@ -338,9 +424,11 @@ describe('project CI workflow', () => { expect(standaloneStep).toContain('cargo fetch \\'); expect(standaloneStep).not.toContain('cargo fetch --locked'); - const nativeJob = jobSection('native-shell-tests'); + const rustJob = jobSection('ai-game-creator-shell-rust-tests'); expect( - nativeJob.indexOf('Prepare standalone Rust crate dependencies'), - ).toBeLessThan(nativeJob.indexOf('run: npm run check:native-shells')); + rustJob.indexOf('Prepare standalone Rust crate dependencies'), + ).toBeLessThan( + rustJob.indexOf('run: npm run check:native-shells:agc-rust'), + ); }); }); From 11beb65c36b014382c417de3501dde623bd9bbe1 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 14 Sep 2026 16:26:10 +0800 Subject: [PATCH 10/13] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=A1=8C=E9=9D=A2?= =?UTF-8?q?=E5=A3=B3=E5=AF=B9=E6=A0=B9=E9=97=A8=E7=A6=81=20label=20?= =?UTF-8?q?=E5=AD=97=E9=9D=A2=E9=87=8F=E7=9A=84=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run 2095 的 Native shell tests 红了,失败点是 desktop-shell 的 typecheck: apps/desktop-shell/scripts/check-config.mjs 断言根门禁脚本必须包含 `console.log('[check:native-shells] desktop-release-binary-artifact')` 字面量, 而拆分时我把 label 挪进了公共打印函数 runNativeShellGate(group, label, gate), 源码里不再有这个字面量,断言随即报 「root native shell gate must keep desktop release artifact check」。 - runNativeShellGate 收窄为 (group, gate):label 改回各门禁执行体里的字面量打印, 分组能力、日志格式与 `--groups=` 语义都不变,外部按字面量做的快照断言继续有效。 - 在该函数旁注明不要把 label 抽成变量:apps/desktop-shell 与 apps/mobile-shell 的 check-config 都会读取根脚本源码做字面量断言。 验证:node --check 通过;contract 分组通过(groups=contract + OK);mobile-shell check-config exit 0;desktop-shell check-config 已越过第 740-777 行的根脚本断言 (本机只卡在本机不存在的 Tauri 生成产物目录,Linux CI 不受影响); vitest scripts/project-ci-workflow.test.ts 11 passed;eslint 通过。 Co-authored-by: DotCraft <273930855+dotcraft-ai@users.noreply.github.com> --- scripts/check-native-shells.mjs | 231 +++++++++++++++----------------- 1 file changed, 106 insertions(+), 125 deletions(-) diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index 816daff0f..8506e641c 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -136,12 +136,11 @@ function runsNativeShellGateGroup(group) { return requestedNativeShellGroups.includes(group); } -function runNativeShellGate(group, label, gate) { +function runNativeShellGate(group, gate) { if (!runsNativeShellGateGroup(group)) { return; } - console.log(`[check:native-shells] ${label}`); gate(); } @@ -227,11 +226,14 @@ function assertRootNativeShellCheckScripts() { } } -runNativeShellGate( - 'contract', - 'root-native-shell-check-scripts', - assertRootNativeShellCheckScripts, -); +// 每个门禁在自己的执行体里打印 `[check:native-shells]