修复资源协调冲突后的手动意图提示
记录资源协调冲突实际清除的排队手动意图并强制提示重新拖动 补充资源重试成功后不重放旧坐标且保留提示的 Hook 时序测试 同步更新资源画布并发合同与长期竞态经验
This commit is contained in:
+10
-1
@@ -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('布局已在其他窗口更新,请重新拖动');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>) => {
|
||||
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<
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 投影、文件路径和未稳定恢复顺序。
|
||||
|
||||
@@ -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 会话有效的状态冒充项目持久化。
|
||||
|
||||
Reference in New Issue
Block a user