From b25b85b525244deb2ac874567335338712d073d3 Mon Sep 17 00:00:00 2001 From: menghao Date: Fri, 31 Jul 2026 11:12:56 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=B5=84=E6=BA=90=E5=8D=8F?= =?UTF-8?q?=E8=B0=83=E5=86=B2=E7=AA=81=E5=90=8E=E7=9A=84=E6=89=8B=E5=8A=A8?= =?UTF-8?q?=E6=84=8F=E5=9B=BE=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 记录资源协调冲突实际清除的排队手动意图并强制提示重新拖动 补充资源重试成功后不重放旧坐标且保留提示的 Hook 时序测试 同步更新资源画布并发合同与长期竞态经验 --- .../useProjectResourceCanvasLayout.ts | 11 ++- .../useProjectResourceCanvasLayout.test.ts | 96 +++++++++++++++++++ ...AI游戏创作】项目开发工作台PRD-2026-07-20.md | 2 +- docs/project-memory/shared-memory/pitfalls.md | 8 ++ ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- 5 files changed, 116 insertions(+), 3 deletions(-) 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 61b10037b..573a0670b 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 @@ -311,6 +311,11 @@ export function useProjectResourceCanvasLayout({ enqueueResourceSyncRef.current(currentScope.epoch); } } else { + const discardedQueuedManualIntent = writeQueueRef.current.some( + (queued) => + queued.scopeEpoch === currentScope.epoch && + queued.kind === 'manual', + ); writeQueueRef.current = writeQueueRef.current.filter( (queued) => queued.scopeEpoch !== currentScope.epoch || @@ -328,7 +333,11 @@ export function useProjectResourceCanvasLayout({ if (willRetryResourceSync) { enqueueResourceSyncRef.current(currentScope.epoch, nextRetry); } - if (intent.kind === 'manual' || !willRetryResourceSync) { + if ( + intent.kind === 'manual' || + discardedQueuedManualIntent || + !willRetryResourceSync + ) { setNotice('布局已在其他窗口更新,请重新拖动'); } } diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts index af07c6d19..cd78e38c1 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts @@ -368,6 +368,102 @@ describe('useProjectResourceCanvasLayout', () => { expect(updateCalls).toBe(1); }); + it('keeps the redrag notice when resource reconciliation drops a queued manual intent and retries', async () => { + const resourceA = resource('resource-a'); + const resourceB = resource('resource-b'); + const updates: Array<{ + expectedRevision: number; + positions: ProjectResourceCanvasPosition[]; + }> = []; + let resolveResourceSync: + | ((result: { + status: 'conflict'; + 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) => { + resolveResourceSync = 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, resourceB], + }), + ); + await waitFor(() => expect(updates).toHaveLength(1)); + + act(() => result.current.commitPosition('resource-a', 'document', 100, 30)); + expect(result.current.layout.positions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ resourceId: 'resource-a', x: 100, y: 30 }), + ]), + ); + expect(updates).toHaveLength(1); + + await act(async () => { + resolveResourceSync?.({ + status: 'conflict', + layout: persistedLayout('dependency', 2, [ + position('resource-a', 400, 80), + ]), + }); + await Promise.resolve(); + }); + + await waitFor(() => expect(updates).toHaveLength(2)); + await waitFor(() => expect(result.current.saving).toBe(false)); + expect(updates.map(({ expectedRevision }) => expectedRevision)).toEqual([ + 1, 2, + ]); + expect(updates[1]?.positions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ resourceId: 'resource-a', x: 400, y: 80 }), + expect.objectContaining({ resourceId: 'resource-b' }), + ]), + ); + expect(updates[1]?.positions).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ resourceId: 'resource-a', x: 100, y: 30 }), + ]), + ); + expect(result.current.layout.positions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ resourceId: 'resource-a', x: 400, y: 80 }), + ]), + ); + expect(result.current.notice).toBe('布局已在其他窗口更新,请重新拖动'); + }); + it('ignores a late read from the previous mode', async () => { const resourceA = resource('resource-a'); const readResolvers = new Map< diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index 70c329065..89dce9373 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -244,7 +244,7 @@ type UpdateProjectResourceCanvasLayoutResult = - 同一 `projectPath + projectId + mode` 的首次读取与资源集合协调必须分开:资源集合变化不得取消已经发出的读取或保存。同一 scope 内全部手动拖动和资源自动协调写入使用同一 FIFO,任一时刻最多一个 CAS 在途,后一笔必须使用前一笔成功返回的 revision,不能用“最后请求获胜”跳过中间 CAS。切换项目或 mode 后,旧 scope 的在途请求不能阻塞新 scope 队列;前端放弃旧请求槽位并丢弃其迟到响应,后端继续依靠 `expectedProjectId + expectedRevision + 系统锁` 仲裁已发出的请求。 - 某笔 CAS 在途期间,同一 scope 内对相同 `resourceId + section` 重复产生但尚未发送的拖动意图必须折叠为最后坐标;已经在途的请求不得取消,不同资源的顺序不得跨越。队列增长必须受当前资源与分区数量约束,不能随连续 pointer 事件无界累积。 - 用户拖动结束后先乐观更新,再立即提交一次 CAS。成功后以返回布局更新 revision;普通写入失败时恢复最近可信持久布局并提示“布局保存失败,已恢复上次布局”。 -- CAS 冲突时直接载入返回的最新布局并提示“布局已在其他窗口更新,请重新拖动”,丢弃所有基于冲突前快照排队的手动拖动,不得自动重放本地旧坐标或静默覆盖另一窗口结果。资源自动协调可以基于冲突返回的新 revision 有界重试,单次资源签名最多追加 `2` 次,持续跨窗口写入时不得无限自旋。 +- CAS 冲突时直接载入返回的最新布局并提示“布局已在其他窗口更新,请重新拖动”,丢弃所有基于冲突前快照排队的手动拖动,不得自动重放本地旧坐标或静默覆盖另一窗口结果。即使当前在途请求是允许自动重试的资源协调,只要本次冲突实际清除了任何排队手动拖动,也必须保留重新拖动提示,后续资源协调重试成功不得静默清除。资源自动协调可以基于冲突返回的新 revision 有界重试,单次资源签名最多追加 `2` 次,持续跨窗口写入时不得无限自旋。 - 缺少 Tauri bridge 的浏览器开发态可以保留当前会话内布局用于界面测试,但不得宣称已经持久保存。 ### 5.3 资源类型与替换兼容性(P1) diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index a0ff3713b..9a8f9c373 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3880,6 +3880,14 @@ - 验证:让旧 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`。 +## 资源协调冲突清除排队拖动时不能静默重试(2026-07-31) + +- 现象:资源协调 CAS 在途期间,用户拖动资源形成排队 manual intent;协调请求随后 conflict 并基于权威 revision 自动重试成功,布局正确保留另一窗口结果,但界面没有提示本地拖动已经被丢弃。 +- 原因:冲突分支虽然清除了当前 scope 的全部 manual intent,却只按“当前 intent 是否为 manual”或“资源协调是否停止重试”决定提示;当前 intent 为 resources 且可重试时,排队拖动的丢弃事实没有进入提示条件。 +- 处理:过滤队列前记录本次是否实际清除了 manual intent。只要当前 manual 发生冲突或清除了任何排队 manual,就必须提示用户重新拖动;冲突前坐标不得自动重放,后续资源协调继续使用冲突响应的权威 revision,并且成功响应不能静默清除提示。 +- 验证:定向 Hook 测试固定“resource sync revision 1 在途、manual 排队、权威 revision 2 conflict、resource retry 成功”时序,断言 retry 使用 revision 2、权威坐标保留、旧 manual 不重放且 notice 仍存在。 +- 关联:`apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts`、`apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts`。 + ## 抽通用 Runtime 时不要把产品持久文件直接变成公共 ABI - 现象:为了快速“抽 crate”,直接把 Tauri package 内的 `AgentRuntimeState`、sidecar struct 或 Runner protocol 改成 `pub`,第二个消费者虽然能编译,却同时绑定游戏 schema、UI 投影、文件路径和未稳定恢复顺序。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 5b730804d..e6bdb9fd1 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -339,7 +339,7 @@ game-project/ - Tauri 命令固定为 `read_local_project_resource_canvas_layout` 与 `update_local_project_resource_canvas_layout`。写命令先只读确认有效 manifest,再通过持久 `.layout.lock` 入口获取句柄级跨窗口系统锁,并在锁内复核 projectId、重新读取当前 sidecar。Unix 使用 `flock`,Windows 使用不共享文件句柄;释放只通过句柄 Drop / 进程退出完成,不使用 mtime stale 回收,也不删除锁文件。其余写入继续复用安全路径、链接检查、容量上限、恢复副本与原子替换能力;不能只依赖 React 状态或进程内锁。 - 前端从当前项目开发大组件中拆出纯布局模型与持久 Hook。默认布局、碰撞检查、资源增删协调和 section 边界由纯模型负责;读取、异步身份、CAS、错误回滚和冲突载入由 Hook 负责。Hook 以 `projectPath + projectId + mode` epoch 隔离异步结果,资源变化不取消首读或在途保存;单窗口写入经同一 FIFO 串行提交,每笔都使用最近一次成功 / 冲突响应的权威 revision。视图使用 Pointer Events 做二维拖动,保存中仍允许继续拖动并排队,普通点击、搜索、筛选和唯一资源详情浮层语义保持不变。 - 新资源只在第一次进入某个 mode 时计算默认不重叠位置;全部现存坐标保持不变。搜索、筛选、窗口 resize 和 mode 切换不得重排或回写已有坐标,窄视图通过 section 画布范围与滚动访问,不裁切持久坐标。 -- 普通保存失败恢复最近可信持久布局;CAS 冲突载入对方最新布局并要求用户重新拖动,同时清除基于旧快照排队的全部手动意图,不自动重放旧坐标。资源自动协调可基于冲突布局最多追加两次重试,持续跨窗口竞争时停止自旋并保留当前会话协调结果。损坏、未知 schema、身份冲突、超限与链接文件失败关闭,不能用空布局覆盖原文件。 +- 普通保存失败恢复最近可信持久布局;CAS 冲突载入对方最新布局并要求用户重新拖动,同时清除基于旧快照排队的全部手动意图,不自动重放旧坐标。即使冲突发生在允许自动重试的资源协调请求上,只要本次冲突清除了排队手动意图,重新拖动提示就必须保留且不能被后续资源协调成功静默清除。资源自动协调可基于冲突布局最多追加两次重试,持续跨窗口竞争时停止自旋并保留当前会话协调结果。损坏、未知 schema、身份冲突、超限与链接文件失败关闭,不能用空布局覆盖原文件。 - 本切片不包含资源关系线、资源替换、详情浮层位置、缩放 / 平移、搜索 / 筛选条件、当前 mode,也不修改 `api-server` 或 SpacetimeDB。关系线与其它 P1 能力必须在本切片独立验收后继续接入。 实施顺序固定为:先同步 TypeScript / Rust DTO 与序列化测试,再实现 Tauri sidecar 读写和 CAS,随后接入前端纯模型、持久 Hook 与二维拖动,最后完成 Rust 安全测试、React 交互测试、跨重启 / 双窗口验收和文档状态回写。任何一步不得用 `localStorage`、manifest 字段或只在当前 React 会话有效的状态冒充项目持久化。