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 981f0616e..67a4f83f4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -207,6 +207,7 @@ pub(crate) fn read_local_project_resource_canvas_layout( #[tauri::command] pub(crate) fn update_local_project_resource_canvas_layout( project_path: String, + expected_project_id: String, mode: ProjectResourceCanvasLayoutMode, expected_revision: u64, positions: Vec, @@ -214,6 +215,7 @@ pub(crate) fn update_local_project_resource_canvas_layout( update_project_resource_canvas_layout_at( Path::new(project_path.trim()), mode, + &expected_project_id, expected_revision, positions, ) diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs index 411c60057..77d70014b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs @@ -432,14 +432,22 @@ pub(crate) fn read_project_resource_canvas_layout_at( pub(crate) fn update_project_resource_canvas_layout_at( root: &Path, mode: ProjectResourceCanvasLayoutMode, + expected_project_id: &str, expected_revision: u64, positions: Vec, ) -> Result { validate_resource_layout_positions(&positions)?; + let expected_project_id = expected_project_id.trim(); + if expected_project_id.is_empty() { + return Err("资源布局 expectedProjectId 不能为空".to_string()); + } let preflight_project_id = current_resource_layout_project_id(root)?; + if preflight_project_id != expected_project_id { + return Err("资源布局 expectedProjectId 与当前项目不匹配".to_string()); + } let _lock = acquire_resource_layout_write_lock(root)?; let project_id = current_resource_layout_project_id(root)?; - if project_id != preflight_project_id { + if project_id != expected_project_id { return Err("项目 manifest 在资源布局锁获取期间发生变化,请重试".to_string()); } let relative_path = resource_layout_relative_path(mode); @@ -456,11 +464,18 @@ pub(crate) fn update_project_resource_canvas_layout_at( layout: current, }); } + let next_revision = current + .revision + .checked_add(1) + .ok_or_else(|| "资源布局 revision 已达到上限,无法继续保存".to_string())?; + if current_resource_layout_project_id(root)? != project_id { + return Err("项目 manifest 在资源布局写入前发生变化,请重试".to_string()); + } let next = ProjectResourceCanvasLayout { schema_version: GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION.to_string(), project_id, mode, - revision: current.revision.saturating_add(1), + revision: next_revision, positions, updated_at: unix_millis().min(u128::from(u64::MAX)) as u64, }; @@ -481,16 +496,22 @@ pub(crate) fn update_project_resource_canvas_layout_at( #[cfg(test)] mod tests { use super::*; - use std::sync::{Arc, Barrier}; + use std::sync::{ + atomic::{AtomicU64, Ordering}, + Arc, Barrier, + }; + + static NEXT_RESOURCE_LAYOUT_TEST_ID: AtomicU64 = AtomicU64::new(0); fn unique_resource_layout_project_path() -> PathBuf { std::env::temp_dir().join(format!( - "genarrative-resource-layout-{}-{}", + "genarrative-resource-layout-{}-{}-{}", std::process::id(), SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() - .as_nanos() + .as_nanos(), + NEXT_RESOURCE_LAYOUT_TEST_ID.fetch_add(1, Ordering::Relaxed) )) } @@ -525,6 +546,7 @@ mod tests { let updated = update_project_resource_canvas_layout_at( &root, ProjectResourceCanvasLayoutMode::Dependency, + "layout-project", 0, vec![layout_position("asset-a", 18)], ) @@ -549,6 +571,7 @@ mod tests { let missing_error = update_project_resource_canvas_layout_at( &missing_root, ProjectResourceCanvasLayoutMode::Dependency, + "missing-project", 0, vec![layout_position("asset-a", 10)], ) @@ -561,6 +584,7 @@ mod tests { update_project_resource_canvas_layout_at( &non_project_root, ProjectResourceCanvasLayoutMode::Dependency, + "non-project", 0, vec![layout_position("asset-a", 10)], ) @@ -576,6 +600,7 @@ mod tests { update_project_resource_canvas_layout_at( &invalid_manifest_root, ProjectResourceCanvasLayoutMode::Dependency, + "invalid-project", 0, vec![layout_position("asset-a", 10)], ) @@ -584,6 +609,25 @@ mod tests { fs::remove_dir_all(invalid_manifest_root).ok(); } + #[test] + fn resource_layout_expected_project_id_fences_stale_windows_before_lock_side_effects() { + let root = unique_resource_layout_project_path(); + init_local_game_project_at(&root, "layout-current-project", "布局当前项目") + .expect("init project"); + + let error = update_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Dependency, + "layout-stale-project", + 0, + vec![layout_position("asset-a", 10)], + ) + .expect_err("stale project window must fail"); + assert!(error.contains("expectedProjectId")); + assert!(!root.join(".agent/workbench").exists()); + fs::remove_dir_all(root).ok(); + } + #[cfg(unix)] #[test] fn resource_layout_live_system_lock_is_not_reclaimed_from_old_mtime() { @@ -627,6 +671,7 @@ mod tests { let first = update_project_resource_canvas_layout_at( &root, ProjectResourceCanvasLayoutMode::Dependency, + "layout-cas", 0, vec![layout_position("asset-a", 10)], ) @@ -636,6 +681,7 @@ mod tests { let conflict = update_project_resource_canvas_layout_at( &root, ProjectResourceCanvasLayoutMode::Dependency, + "layout-cas", 0, vec![layout_position("asset-a", 999)], ) @@ -656,6 +702,39 @@ mod tests { fs::remove_dir_all(root).ok(); } + #[test] + fn resource_layout_revision_exhaustion_fails_without_overwrite() { + let root = unique_resource_layout_project_path(); + init_local_game_project_at(&root, "layout-revision-max", "布局 revision 上限") + .expect("init project"); + let path = root.join(resource_layout_relative_path( + ProjectResourceCanvasLayoutMode::Dependency, + )); + fs::create_dir_all(path.parent().expect("layout parent")).expect("create layout parent"); + let exhausted = ProjectResourceCanvasLayout { + schema_version: GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION.to_string(), + project_id: "layout-revision-max".to_string(), + mode: ProjectResourceCanvasLayoutMode::Dependency, + revision: u64::MAX, + positions: vec![layout_position("asset-a", 10)], + updated_at: 1, + }; + let original = serde_json::to_vec(&exhausted).expect("serialize exhausted layout"); + fs::write(&path, &original).expect("write exhausted layout"); + + let error = update_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Dependency, + "layout-revision-max", + u64::MAX, + vec![layout_position("asset-a", 999)], + ) + .expect_err("revision exhaustion must fail"); + assert!(error.contains("revision")); + assert_eq!(fs::read(&path).expect("read exhausted layout"), original); + fs::remove_dir_all(root).ok(); + } + #[test] fn resource_layout_rejects_duplicate_ids_and_project_identity_drift() { let root = unique_resource_layout_project_path(); @@ -663,6 +742,7 @@ mod tests { let duplicate_error = update_project_resource_canvas_layout_at( &root, ProjectResourceCanvasLayoutMode::Type, + "layout-identity", 0, vec![ layout_position("asset-a", 10), @@ -737,6 +817,7 @@ mod tests { let coordinate_error = update_project_resource_canvas_layout_at( &root, ProjectResourceCanvasLayoutMode::Type, + "layout-invalid", 0, vec![layout_position( "asset-coordinate", @@ -748,6 +829,7 @@ mod tests { let id_error = update_project_resource_canvas_layout_at( &root, ProjectResourceCanvasLayoutMode::Type, + "layout-invalid", 0, vec![layout_position( &"x".repeat(RESOURCE_LAYOUT_MAX_RESOURCE_ID_CHARS + 1), @@ -815,6 +897,7 @@ mod tests { update_project_resource_canvas_layout_at( &root, ProjectResourceCanvasLayoutMode::Dependency, + "layout-concurrent", 0, vec![layout_position("asset-a", x)], ) diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs index f24c11c8e..bb3167a00 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs @@ -2151,8 +2151,7 @@ fn visual_specialist_delegations_require_image_artifacts_but_read_only_work_allo fn visual_specialist_delegation_degrades_to_text_artifacts_without_editor_api_key() { let _config_guard = crate::tests::write_test_local_config("{}".to_string()); let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "无图片密钥委派合同测试") - .expect("project init"); + init_local_game_project_at(&root, "project-1", "无图片密钥委派合同测试").expect("project init"); let parent_run_id = "text-only-design-delegate-parent-run"; start_game_creator_agent_runtime_task_at( &root, diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index f9f223696..cec14b71b 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -4,6 +4,7 @@ import { type ChangeEvent, type FormEvent, type UIEvent, + useCallback, useEffect, useLayoutEffect, useRef, @@ -64,10 +65,10 @@ import type { LocalGameMemoryResult, LocalPreviewResult, LocalPreviewStatus, - LocalProjectDirectoryStatus, LocalProjectCheckpointResult, LocalProjectCheckpointSummary, LocalProjectDiffResult, + LocalProjectDirectoryStatus, LocalProjectExportPackageResult, LocalProjectExportPackagesResult, LocalProjectFileEntry, @@ -200,6 +201,7 @@ import { } from './features/project-workspace/agentRunTrace'; import { DeveloperProjectPanels } from './features/project-workspace/DeveloperProjectPanels'; import { DeveloperRuntimePanels } from './features/project-workspace/DeveloperRuntimePanels'; +import { resolveEmbeddedPreviewUrl } from './features/project-workspace/LocalGamePreviewFrame'; import { appendMemoryContent, memoryScopeLabel, @@ -221,7 +223,6 @@ import { import { handleProjectSummaryChatCommand } from './features/project-workspace/projectSummaryCommands'; import { ProjectSupervisorView } from './features/project-workspace/ProjectSupervisorView'; import { ProjectWorkspaceChatPane } from './features/project-workspace/ProjectWorkspaceChatPane'; -import { resolveEmbeddedPreviewUrl } from './features/project-workspace/LocalGamePreviewFrame'; import { buildGameChatProgressEvidence, collectGameChatResultImages, @@ -596,6 +597,9 @@ export function App({ ) => Promise) | null >(null); + const executeChatAgentReplyRef = useRef< + (prompt: string) => Promise + >(async () => undefined); const agentConversationSavingRef = useRef(false); const agentConversationBackgroundBusyRef = useRef(false); const agentConversationLoadVersionRef = useRef(0); @@ -620,27 +624,30 @@ export function App({ storeGameChatAutoPreviewAuthorization(authorization); } - function updateProjectSupervisorRuntime( - runtime: AgentRuntimeState | null, - previous = projectSupervisorRuntimeRef.current, - ) { - const nextRuntime = runtime - ? normalizeAgentRuntimeState(runtime, previous) - : null; - const nextProjectPath = localProjectPathRef.current; - if ( - gameChatOnly && - nextProjectPath && - nextRuntime?.runId && - !isAgentRuntimeTerminalState(nextRuntime) - ) { - gameChatObservedRunKeysRef.current.add( - `${nextProjectPath}\n${nextRuntime.runId}`, - ); - } - projectSupervisorRuntimeRef.current = nextRuntime; - setProjectSupervisorRuntime(nextRuntime); - } + const updateProjectSupervisorRuntime = useCallback( + ( + runtime: AgentRuntimeState | null, + previous = projectSupervisorRuntimeRef.current, + ) => { + const nextRuntime = runtime + ? normalizeAgentRuntimeState(runtime, previous) + : null; + const nextProjectPath = localProjectPathRef.current; + if ( + gameChatOnly && + nextProjectPath && + nextRuntime?.runId && + !isAgentRuntimeTerminalState(nextRuntime) + ) { + gameChatObservedRunKeysRef.current.add( + `${nextProjectPath}\n${nextRuntime.runId}`, + ); + } + projectSupervisorRuntimeRef.current = nextRuntime; + setProjectSupervisorRuntime(nextRuntime); + }, + [gameChatOnly], + ); function updateProjectSupervisorResponseStream( incoming: AgentRuntimeResponseStream | null | undefined, @@ -946,7 +953,7 @@ export function App({ disposed = true; cleanup?.(); }; - }, []); + }, [updateProjectSupervisorRuntime]); useEffect(() => { const invoke = resolveTauriInvoke(); @@ -5149,6 +5156,8 @@ export function App({ } } + executeChatAgentReplyRef.current = executeChatAgentReply; + useEffect(() => { const latch = initialSupervisorMessageLatchRef.current; if (!gameChatOnly || !latch.prompt || !localProject) { @@ -5169,7 +5178,7 @@ export function App({ ...current, { role: 'user', text: latch.prompt, runtimeOwned: true }, ]); - void executeChatAgentReply(latch.prompt); + void executeChatAgentReplyRef.current(latch.prompt); }, [chatAgentBusy, gameChatOnly, initialSupervisorMessage, localProject]); async function handleProjectSupervisorToolAction( diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx index 40a425b5a..401757601 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx @@ -1,3 +1,5 @@ +/* eslint-disable react-refresh/only-export-components -- The URL guard is exported with its small rendering adapter for focused tests. */ + export type LocalGamePreviewLike = { status?: string | null; url?: string | null; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx index f3ce2ea79..0b886a469 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx @@ -1,3 +1,5 @@ +/* eslint-disable react-refresh/only-export-components -- Testable game-chat presentation helpers share this focused view module. */ + import { FolderOpen, Send, Settings } from 'lucide-react'; import type { ComponentProps, @@ -21,12 +23,12 @@ import type { import { formatAgentRuntimeEvent, isAgentRuntimeTerminalState, - projectProfessionalAgentLabel, projectNameFromPath, + projectProfessionalAgentLabel, projectRuntimePlanProgress, projectRuntimeVisibleCurrentWork, - projectSupervisorCollaboratingAgentRuntimes, projectSupervisorChatRuntimeStatus, + projectSupervisorCollaboratingAgentRuntimes, ProjectSupervisorRuntimeControls, } from '../agent-runtime'; import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog'; @@ -111,7 +113,12 @@ function gameChatResultImageLabel(kind: string, path: string) { } function isGameChatResultImagePath(path: string) { - if (/[\\\u0000-\u001f\u007f]/u.test(path)) { + if ( + Array.from(path).some((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return character === '\\' || codePoint <= 0x1f || codePoint === 0x7f; + }) + ) { return false; } const segments = path.split('/'); diff --git a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts index f86d2f989..61b10037b 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts @@ -284,6 +284,7 @@ export function useProjectResourceCanvasLayout({ 'update_local_project_resource_canvas_layout', { projectPath: scope.projectPath, + expectedProjectId: scope.projectId, mode: scope.mode, expectedRevision: persistedLayoutRef.current.revision, positions: candidate.positions, @@ -315,19 +316,21 @@ export function useProjectResourceCanvasLayout({ queued.scopeEpoch !== currentScope.epoch || queued.kind !== 'manual', ); - setNotice('布局已在其他窗口更新,请重新拖动'); const needsResourceSync = reconcileResourceCanvasLayout( result.layout, resourcesRef.current, ).changed; const nextRetry = intent.kind === 'resources' ? intent.conflictRetries + 1 : 0; - if ( + const willRetryResourceSync = needsResourceSync && - nextRetry <= MAX_RESOURCE_SYNC_CONFLICT_RETRIES - ) { + nextRetry <= MAX_RESOURCE_SYNC_CONFLICT_RETRIES; + if (willRetryResourceSync) { enqueueResourceSyncRef.current(currentScope.epoch, nextRetry); } + if (intent.kind === 'manual' || !willRetryResourceSync) { + setNotice('布局已在其他窗口更新,请重新拖动'); + } } rebuildOptimisticLayout(currentScope.epoch); }) @@ -370,6 +373,7 @@ export function useProjectResourceCanvasLayout({ }; initializedScopeEpochRef.current = null; writeQueueRef.current = []; + activeWriteIntentRef.current = null; }; }, []); @@ -385,6 +389,7 @@ export function useProjectResourceCanvasLayout({ scopeRef.current = scope; initializedScopeEpochRef.current = null; writeQueueRef.current = []; + activeWriteIntentRef.current = null; const initialFallback = reconcileResourceCanvasLayout( createEmptyResourceCanvasLayout(projectId, mode), resourcesRef.current, @@ -489,15 +494,27 @@ export function useProjectResourceCanvasLayout({ if (scope.key !== scopeKey) { return; } - const intent: ManualLayoutWriteIntent = { - kind: 'manual', - scopeEpoch: scope.epoch, - resourceId, - section, - x, - y, - }; - writeQueueRef.current.push(intent); + const queuedIntent = writeQueueRef.current.find( + (intent): intent is ManualLayoutWriteIntent => + intent.kind === 'manual' && + intent.scopeEpoch === scope.epoch && + intent.resourceId === resourceId && + intent.section === section && + intent !== activeWriteIntentRef.current, + ); + if (queuedIntent) { + queuedIntent.x = x; + queuedIntent.y = y; + } else { + writeQueueRef.current.push({ + kind: 'manual', + scopeEpoch: scope.epoch, + resourceId, + section, + x, + y, + }); + } applyLayout( moveResourceCanvasPosition( layoutRef.current, diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts index 6cec13fda..af07c6d19 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts @@ -65,6 +65,7 @@ describe('useProjectResourceCanvasLayout', () => { let resolveRead: ((layout: ProjectResourceCanvasLayout) => void) | null = null; const updates: Array<{ + expectedProjectId: string; expectedRevision: number; positions: ProjectResourceCanvasPosition[]; }> = []; @@ -77,6 +78,7 @@ describe('useProjectResourceCanvasLayout', () => { } if (command === 'update_local_project_resource_canvas_layout') { const input = args as { + expectedProjectId: string; expectedRevision: number; positions: ProjectResourceCanvasPosition[]; }; @@ -119,6 +121,7 @@ describe('useProjectResourceCanvasLayout', () => { result.current.layout.positions.map(({ resourceId }) => resourceId), ).toEqual(expect.arrayContaining(['resource-a', 'resource-b'])); expect(updates[0]?.expectedRevision).toBe(1); + expect(updates[0]?.expectedProjectId).toBe(projectId); expect( updates[0]?.positions.some( ({ resourceId }) => resourceId === 'resource-b', @@ -222,6 +225,89 @@ describe('useProjectResourceCanvasLayout', () => { ).toBe(true); }); + it('coalesces repeated queued drags for the same resource behind an in-flight CAS', async () => { + const resourceA = resource('resource-a'); + const updates: Array<{ + expectedRevision: number; + positions: ProjectResourceCanvasPosition[]; + }> = []; + let resolveFirstUpdate: + | ((result: { + status: 'updated'; + layout: ProjectResourceCanvasLayout; + }) => void) + | null = null; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_canvas_layout') { + return persistedLayout('dependency', 1, [ + position('resource-a', 10, 20), + ]); + } + if (command === 'update_local_project_resource_canvas_layout') { + const input = args as { + expectedRevision: number; + positions: ProjectResourceCanvasPosition[]; + }; + updates.push(structuredClone(input)); + if (updates.length === 1) { + return await new Promise((resolve) => { + resolveFirstUpdate = resolve; + }); + } + return { + status: 'updated', + layout: persistedLayout( + 'dependency', + input.expectedRevision + 1, + structuredClone(input.positions), + ), + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + const { result } = renderHook(() => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode: 'dependency', + resources: [resourceA], + }), + ); + await waitFor(() => expect(result.current.layout.positions[0]?.x).toBe(10)); + + act(() => result.current.commitPosition('resource-a', 'document', 100, 30)); + await waitFor(() => expect(updates).toHaveLength(1)); + act(() => { + for (let x = 200; x < 300; x += 1) { + result.current.commitPosition('resource-a', 'document', x, 40); + } + }); + expect(updates).toHaveLength(1); + expect(result.current.layout.positions[0]?.x).toBe(299); + + await act(async () => { + resolveFirstUpdate?.({ + status: 'updated', + layout: persistedLayout( + 'dependency', + 2, + structuredClone(updates[0]!.positions), + ), + }); + await Promise.resolve(); + }); + + await waitFor(() => expect(updates).toHaveLength(2)); + expect(updates.map(({ expectedRevision }) => expectedRevision)).toEqual([ + 1, 2, + ]); + expect(updates[1]?.positions[0]?.x).toBe(299); + await waitFor(() => expect(result.current.saving).toBe(false)); + }); + it('drops manual intents queued behind a CAS conflict', async () => { const resourceA = resource('resource-a'); let updateCalls = 0; @@ -332,7 +418,7 @@ describe('useProjectResourceCanvasLayout', () => { expect(result.current.layout.positions[0]).toMatchObject({ x: 200, y: 50 }); }); - it('waits for an old-scope write to settle before pumping the new scope queue', async () => { + it('does not let a stuck old-scope write block the new scope queue', async () => { const resourceA = resource('resource-a'); const updates: Array<{ mode: ProjectResourceCanvasLayoutMode; @@ -388,7 +474,11 @@ describe('useProjectResourceCanvasLayout', () => { rerender({ mode: 'type' }); await waitFor(() => expect(result.current.layout.mode).toBe('type')); act(() => result.current.commitPosition('resource-a', 'document', 300, 40)); - expect(updates).toEqual([{ mode: 'dependency', expectedRevision: 1 }]); + await waitFor(() => expect(updates).toHaveLength(2)); + expect(updates[1]).toEqual({ mode: 'type', expectedRevision: 5 }); + await waitFor(() => expect(result.current.saving).toBe(false)); + expect(result.current.layout.mode).toBe('type'); + expect(result.current.layout.positions[0]?.x).toBe(300); await act(async () => { resolveDependencyUpdate?.({ @@ -400,9 +490,7 @@ describe('useProjectResourceCanvasLayout', () => { await Promise.resolve(); }); - await waitFor(() => expect(updates).toHaveLength(2)); - expect(updates[1]).toEqual({ mode: 'type', expectedRevision: 5 }); - await waitFor(() => expect(result.current.saving).toBe(false)); + expect(updates).toHaveLength(2); expect(result.current.layout.mode).toBe('type'); expect(result.current.layout.positions[0]?.x).toBe(300); }); @@ -441,6 +529,7 @@ describe('useProjectResourceCanvasLayout', () => { }), ); + await waitFor(() => expect(expectedRevisions).toHaveLength(3)); await waitFor(() => expect(result.current.saving).toBe(false)); expect(expectedRevisions).toEqual([1, 2, 3]); expect(result.current.notice).toBe('布局已在其他窗口更新,请重新拖动'); diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index d329a1549..70c329065 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -209,6 +209,7 @@ type ReadProjectResourceCanvasLayoutInput = { type UpdateProjectResourceCanvasLayoutInput = { projectPath: string; + expectedProjectId: string; mode: 'dependency' | 'type'; expectedRevision: number; positions: ProjectResourceCanvasLayout['positions']; @@ -226,8 +227,9 @@ type UpdateProjectResourceCanvasLayoutResult = ``` - 读取命令固定为 `read_local_project_resource_canvas_layout`,返回当前 mode 的完整布局;文件不存在时返回合成的 revision `0` 布局,不为只读操作创建目录或文件。 -- 更新命令固定为 `update_local_project_resource_canvas_layout`。调用方不提交 `projectId / revision / updatedAt` 的权威新值;Tauri 必须先只读确认项目存在且 manifest 有效,随后获取系统锁并在锁内复核 `projectId`、重新读取当前布局,再生成新的 revision 与时间戳。不存在目录、普通非项目目录或损坏 manifest 均不得先创建 `.agent/workbench`、锁文件或布局文件。 +- 更新命令固定为 `update_local_project_resource_canvas_layout`。调用方只提交当前已读取布局的 `expectedProjectId` 身份栅栏,不提交 `projectId / revision / updatedAt` 的权威新值;Tauri 必须先只读确认项目存在、manifest 有效且 projectId 与栅栏一致,随后获取系统锁并在锁内复核 `projectId`、重新读取当前布局,再生成新的 revision 与时间戳。路径被其它窗口重建为新项目时,旧窗口必须在任何布局副作用前失败。不存在目录、普通非项目目录或损坏 manifest 均不得先创建 `.agent/workbench`、锁文件或布局文件。 - `expectedRevision` 与锁内 revision 相同才允许原子写入并返回 `updated`;不同时不得写文件,返回 `conflict` 和锁内最新完整布局。前端不得通过解析错误字符串识别 CAS 冲突。 +- revision 使用 `u64` 且每次成功必须严格递增;当前值已经是 `u64::MAX` 时失败关闭并保持原文件不变,不得饱和后继续以相同 revision 返回成功。 - 项目无效、布局损坏、字段校验失败和文件系统错误继续作为安全、可理解的 Tauri command error 返回;错误不得包含配置、凭据或项目外绝对路径。 #### 5.2.4 前端布局与协调合同 @@ -239,7 +241,8 @@ type UpdateProjectResourceCanvasLayoutResult = - 搜索或筛选只隐藏卡片,不删除、压缩或重排其坐标;清空搜索后恢复原位置。 - 窗口尺寸变化只改变可视范围和分区滚动边界,不回写、裁切或缩放持久坐标。当前客户端继续以 `1280×800` 横屏合同验收。 - 打开项目、切换 mode 或当前 mode 首次出现新资源时执行“读取 -> 协调 -> 必要时 CAS 写入”;项目或 mode 已切换后返回的旧异步结果必须丢弃。 -- 同一 `projectPath + projectId + mode` 的首次读取与资源集合协调必须分开:资源集合变化不得取消已经发出的读取或保存。单窗口内全部手动拖动和资源自动协调写入使用同一 FIFO,任一时刻最多一个 CAS 在途,后一笔必须使用前一笔成功返回的 revision,不能用“最后请求获胜”跳过中间 CAS。 +- 同一 `projectPath + projectId + mode` 的首次读取与资源集合协调必须分开:资源集合变化不得取消已经发出的读取或保存。同一 scope 内全部手动拖动和资源自动协调写入使用同一 FIFO,任一时刻最多一个 CAS 在途,后一笔必须使用前一笔成功返回的 revision,不能用“最后请求获胜”跳过中间 CAS。切换项目或 mode 后,旧 scope 的在途请求不能阻塞新 scope 队列;前端放弃旧请求槽位并丢弃其迟到响应,后端继续依靠 `expectedProjectId + expectedRevision + 系统锁` 仲裁已发出的请求。 +- 某笔 CAS 在途期间,同一 scope 内对相同 `resourceId + section` 重复产生但尚未发送的拖动意图必须折叠为最后坐标;已经在途的请求不得取消,不同资源的顺序不得跨越。队列增长必须受当前资源与分区数量约束,不能随连续 pointer 事件无界累积。 - 用户拖动结束后先乐观更新,再立即提交一次 CAS。成功后以返回布局更新 revision;普通写入失败时恢复最近可信持久布局并提示“布局保存失败,已恢复上次布局”。 - CAS 冲突时直接载入返回的最新布局并提示“布局已在其他窗口更新,请重新拖动”,丢弃所有基于冲突前快照排队的手动拖动,不得自动重放本地旧坐标或静默覆盖另一窗口结果。资源自动协调可以基于冲突返回的新 revision 有界重试,单次资源签名最多追加 `2` 次,持续跨窗口写入时不得无限自旋。 - 缺少 Tauri bridge 的浏览器开发态可以保留当前会话内布局用于界面测试,但不得宣称已经持久保存。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 720212e89..78c01b9a1 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -20,7 +20,7 @@ - 背景:项目开发工作台当前只在 React 会话内保存同分类资源的一维拖拽顺序,项目切换或客户端重启后重建默认排列;工作台 PRD 虽已给出二维位置字段,但缺少落盘路径、坐标系、Tauri API、CAS、异常与安全边界,仍不足以直接编码。 - 决策:dependency 与 type 两套布局分别保存为项目内 `.agent/workbench/resource-layouts/dependency.json` 和 `type.json`,统一使用 `game-creator-resource-layout.v1`。`x / y` 是 section 内容 CSS 像素,revision 从缺文件时的 `0` 单调递增;新资源首次默认放置,任何已有坐标不因排序、筛选、模式切换或 resize 被自动覆盖。 -- 并发与失败:Tauri 用 `read_local_project_resource_canvas_layout` 和 `update_local_project_resource_canvas_layout` 暴露读写,以 `projectId + mode + expectedRevision` 在专用跨窗口布局锁内做 CAS。锁入口文件持久存在,Unix 以 `flock` 文件描述符、Windows 以不共享句柄持有互斥;应用不按 mtime / PID 猜测 stale、不删除锁文件,进程退出由操作系统释放。更新在创建锁目录前只读验证 manifest,锁内复核 projectId;无效根保持零 workbench 副作用。前端以 project/path/mode epoch 丢弃旧 scope 迟到响应,资源变化不得取消首读或在途写;同一窗口的手动拖动与资源协调进入单写者 FIFO,后一笔只使用前一笔权威响应的 revision。冲突返回最新完整布局且零写入,前端载入最新值、丢弃基于旧快照排队的手动拖动并要求重新操作;资源协调最多追加两次冲突重试,普通失败恢复最近可信布局。写入复用项目安全路径、链接校验、容量上限、恢复副本与原子替换,损坏或身份冲突不能被空布局覆盖。 +- 并发与失败:Tauri 用 `read_local_project_resource_canvas_layout` 和 `update_local_project_resource_canvas_layout` 暴露读写,以 `projectId + mode + expectedRevision` 在专用跨窗口布局锁内做 CAS。更新额外携带只读结果中的 `expectedProjectId` 身份栅栏,路径被重建为新项目时旧窗口在锁副作用前失败;revision 使用 checked increment,耗尽时保持原文件。锁入口文件持久存在,Unix 以 `flock` 文件描述符、Windows 以不共享句柄持有互斥;应用不按 mtime / PID 猜测 stale、不删除锁文件,进程退出由操作系统释放。更新在创建锁目录前只读验证 manifest,锁内复核 projectId;无效根保持零 workbench 副作用。前端以 project/path/mode epoch 丢弃旧 scope 迟到响应,资源变化不得取消首读或同 scope 在途写;同 scope 的手动拖动与资源协调进入单写者 FIFO,后一笔只使用前一笔权威响应的 revision。切换 scope 会释放旧活动槽,旧请求即使卡死也不能阻塞新 scope;同资源尚未发送的连续拖动折叠为最后坐标,已经在途的 CAS 不取消。冲突返回最新完整布局且零写入,前端载入最新值、丢弃基于旧快照排队的手动拖动并要求重新操作;资源协调最多追加两次冲突重试,普通失败恢复最近可信布局。写入复用项目安全路径、链接校验、容量上限、恢复副本与原子替换,损坏或身份冲突不能被空布局覆盖。 - 业务边界:布局是本地工作台 UI sidecar,不进入 manifest,不推进游戏项目 mutation revision,不使 Runtime verification 失效,不触发 Agent 权限,也不属于资产、Agent 产物、Git 或云端事实。本切片不包含关系线、资源替换、浮层位置、缩放 / 平移、搜索 / 筛选条件和当前 mode。 - 影响范围:`packages/shared` 与 Rust `shared-contracts` 的跨边界 DTO、AI 游戏创作 Tauri 项目持久层与命令、项目开发资源画布、定向 Rust / React 测试、工作台 PRD 和客户端实施计划。 - 验证方式:序列化与字段上限测试、缺文件 / 损坏 / 原子恢复 / 链接安全测试、同 revision 双写最多一个成功、两种 mode 跨重启独立恢复、新增资源不移动旧坐标、`1280×800` 横屏无页面级溢出,以及 `npm run agc:typecheck`、定向测试、`npm run check:encoding`、`git diff --check`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index f79db177e..de73d7ac8 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3867,6 +3867,14 @@ - 现象:两个窗口基于同一 revision 保存资源布局时,正常测试看似只有一个成功;锁文件超过 stale 阈值或两个竞争者同时判断过期时,却可能各自删除 / 重建锁并同时进入 read-check-write,击穿“同 revision 最多一个成功”。无效绝对路径还会在 manifest 报错前遗留 `.agent/workbench/resource-layouts`。 - 原因:`create_new` 只保证某一时刻创建文件原子,不保证“判断过期 → 删除 → 重建”整体原子;mtime 不能证明 owner 已退出,token 文本也不能阻止另一个竞争者删除新锁。先获取锁再读 manifest 又把目录创建副作用提前到了项目身份验证之前。 -- 处理:锁文件作为持久入口永不由应用删除;Unix 用文件描述符持有 `flock(LOCK_EX | LOCK_NB)`,Windows 用 `share_mode(0)` 独占句柄,Drop / 进程退出让操作系统释放锁。安全打开逐级拒绝符号链接 / reparse point,Unix 还核对 owner、硬链接数、inode 和 `0600`。更新先只读验证 manifest,再获取系统锁并在锁内复核 projectId;不存在根、非项目根和损坏 manifest 都不能创建 workbench。 -- 验证:必须覆盖活锁 mtime 被设为 epoch 后竞争者仍拿不到锁、释放后同一 inode 可重新获取、同 revision 并发双写仍恰好一个 updated / 一个 conflict,以及三类无效根零 workbench 副作用。锁等待超时只能返回可重试错误,不得转为 stale 删除。 +- 处理:锁文件作为持久入口永不由应用删除;Unix 用文件描述符持有 `flock(LOCK_EX | LOCK_NB)`,Windows 用 `share_mode(0)` 独占句柄,Drop / 进程退出让操作系统释放锁。安全打开逐级拒绝符号链接 / reparse point,Unix 还核对 owner、硬链接数、inode 和 `0600`。更新携带只用于校验的 `expectedProjectId`,先只读验证 manifest,再获取系统锁并在锁内复核 projectId;不存在根、非项目根、损坏 manifest 和路径复用后的旧窗口都不能创建 workbench。revision 必须 checked increment,耗尽时不能饱和成功。 +- 验证:必须覆盖活锁 mtime 被设为 epoch 后竞争者仍拿不到锁、释放后同一 inode 可重新获取、同 revision 并发双写仍恰好一个 updated / 一个 conflict,三类无效根和旧 projectId 零 workbench 副作用,以及 `u64::MAX` revision 保持原文件。锁等待超时只能返回可重试错误,不得转为 stale 删除。 - 关联:`apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs`、`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`。 + +## 旧 scope 的卡死请求不能占住新资源画布队列(2026-07-30) + +- 现象:用户在 dependency 布局保存尚未返回时切到 type 或另一个项目,新 scope 已完成读取且拖动已进入队列,但因为全局活动请求引用仍指向旧 scope,新的保存会无限等待旧请求结束。 +- 原因:epoch 只阻止迟到响应覆盖新状态,不会自动释放前端单写者槽;把“不能取消已经发出的请求”误写成“所有后续 scope 都必须等待它”,会把一个网络或 IPC 卡死扩大到整个 Hook 生命周期。 +- 处理:FIFO 和单写者只约束同一 `projectPath + projectId + mode` scope。切换 scope 或卸载时立即放弃旧活动槽并清空旧队列,旧 Promise 仍可在后台结束,但其结果由 epoch 丢弃,finally 也只能按意图身份清理自己,不能清掉新 scope 的活动请求。后端继续用 `expectedProjectId`、CAS revision 和系统锁仲裁已经发出的旧写入。同 scope 在途 CAS 不取消;其后相同资源与 section 的排队拖动只保留最后坐标,避免连续输入造成无界队列。 +- 验证:让旧 mode 更新 Promise 永不先 resolve,切换 mode 后应立即发送并完成新 mode CAS;随后再 resolve 旧请求,新布局、saving 状态和请求数均不得变化。另以百次同资源拖动证明在途请求之后只追加一笔、坐标为最后一次输入。 +- 关联:`apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts`、`apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts`。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index d6acf9ea5..8ab2a4e96 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -344,9 +344,9 @@ game-project/ 实施顺序固定为:先同步 TypeScript / Rust DTO 与序列化测试,再实现 Tauri sidecar 读写和 CAS,随后接入前端纯模型、持久 Hook 与二维拖动,最后完成 Rust 安全测试、React 交互测试、跨重启 / 双窗口验收和文档状态回写。任何一步不得用 `localStorage`、manifest 字段或只在当前 React 会话有效的状态冒充项目持久化。 -2026-07-30 前端并发与性能加固状态:首读、资源更新和拖动保存已拆成 scope epoch + 单写者 FIFO;定向 Hook 测试覆盖首读期间资源变化、在途手动 CAS 后资源协调、冲突清除排队拖动、旧 mode 迟到读取 / 写入、新 scope 队列唤醒和持续冲突有界停止。默认布局用 section 分组与二维占用索引替代逐 slot 全量扫描,dependency 使用按列单调游标,type 使用单调 slot 游标;`4096` 项双模式性能回归纳入前端测试,避免恢复到接近 `O(N³)` 的主线程阻塞实现。 +2026-07-30 前端并发与性能加固状态:首读、资源更新和拖动保存已拆成 scope epoch + scope 内单写者 FIFO;定向 Hook 测试覆盖首读期间资源变化、在途手动 CAS 后资源协调、冲突清除排队拖动、旧 mode 迟到读取 / 写入、新 scope 不等待旧 scope 卡死请求、持续冲突有界停止,以及在途 CAS 后同资源连续拖动折叠为最后坐标。旧 scope 请求已经发出后不做不安全取消,但会释放前端活动槽并由 epoch 丢弃迟到响应;同 scope 的在途请求仍保持唯一。默认布局用 section 分组与二维占用索引替代逐 slot 全量扫描,dependency 使用按列单调游标,type 使用单调 slot 游标;`4096` 项双模式性能回归纳入前端测试,避免恢复到接近 `O(N³)` 的主线程阻塞实现。 -2026-07-30 Rust 并发与零副作用加固状态:资源布局锁已由 `create_new + mtime stale 删除` 改为持久锁文件上的 Unix `flock` / Windows 独占句柄,活锁即使 mtime 很旧也不能被另一个写入者回收,释放后仍复用同一文件实例。更新命令在任何目录创建前先读取 manifest,锁内再次核对 projectId;不存在根、非项目根和损坏 manifest 的回归测试均确认不产生 `.agent/workbench`。 +2026-07-30 Rust 并发与零副作用加固状态:资源布局锁已由 `create_new + mtime stale 删除` 改为持久锁文件上的 Unix `flock` / Windows 独占句柄,活锁即使 mtime 很旧也不能被另一个写入者回收,释放后仍复用同一文件实例。更新命令携带只用于校验的 `expectedProjectId`,在任何目录创建前先读取 manifest 并拒绝旧项目窗口,锁内再次核对 projectId;不存在根、非项目根、损坏 manifest 和路径重建后的旧窗口均不产生 `.agent/workbench`。revision 使用 checked increment,`u64::MAX` 时保持原文件并失败关闭,不能让饱和值击穿 CAS。 ## 分阶段实施 diff --git a/server-rs/crates/platform-image/tests/vector_engine.rs b/server-rs/crates/platform-image/tests/vector_engine.rs index c4b1801db..f1bd4470b 100644 --- a/server-rs/crates/platform-image/tests/vector_engine.rs +++ b/server-rs/crates/platform-image/tests/vector_engine.rs @@ -351,15 +351,16 @@ async fn vector_engine_deadline_clips_stalled_attempt_and_prevents_retry() { } }); - let started_at = Instant::now(); - let settings = VectorEngineImageSettings { + let mut settings = VectorEngineImageSettings { base_url: format!("http://{server_addr}/v1"), api_key: "test-key".to_string(), request_timeout_ms: 5_000, - request_deadline: Some(started_at + Duration::from_millis(150)), + request_deadline: None, }; let http_client = build_vector_engine_image_http_client(&settings).expect("client should build"); + let started_at = Instant::now(); + settings.request_deadline = Some(started_at + Duration::from_secs(1)); let error = create_vector_engine_image_generation( &http_client, @@ -379,9 +380,16 @@ async fn vector_engine_deadline_clips_stalled_attempt_and_prevents_retry() { PlatformImageError::Request { timeout: true, .. } )); assert!( - started_at.elapsed() < Duration::from_secs(1), + started_at.elapsed() < Duration::from_secs(3), "attempt 应使用剩余 deadline,而不是完整配置 timeout" ); + tokio::time::timeout(Duration::from_secs(1), async { + while request_count.load(Ordering::SeqCst) == 0 { + tokio::task::yield_now().await; + } + }) + .await + .expect("mock server should observe the single attempted request"); assert_eq!(request_count.load(Ordering::SeqCst), 1); server.abort(); } diff --git a/server-rs/crates/shared-contracts/src/game_creation_app.rs b/server-rs/crates/shared-contracts/src/game_creation_app.rs index be79b9a64..6157a269d 100644 --- a/server-rs/crates/shared-contracts/src/game_creation_app.rs +++ b/server-rs/crates/shared-contracts/src/game_creation_app.rs @@ -293,7 +293,9 @@ pub fn new_game_creation_app_seed_tasks() -> Vec { "game/game_design.md", "assets/ui-prototype.png", ], - ["核心循环、胜负条件和第一版关卡目标明确,且已基于规范图生成可读的 16:9 横屏界面原型图"], + [ + "核心循环、胜负条件和第一版关卡目标明确,且已基于规范图生成可读的 16:9 横屏界面原型图", + ], ), task( "balance-director", @@ -1358,7 +1360,9 @@ mod tests { ); assert_eq!( design.acceptance_criteria, - ["核心循环、胜负条件和第一版关卡目标明确,且已基于规范图生成可读的 16:9 横屏界面原型图"] + [ + "核心循环、胜负条件和第一版关卡目标明确,且已基于规范图生成可读的 16:9 横屏界面原型图" + ] ); let art = manifest