From 028b20a590c65675c05ac643f04ef4917f11df85 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sat, 12 Sep 2026 13:30:18 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=B8=85=E5=8D=95=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=E8=AF=AF=E5=88=A4=EF=BC=9ACAS=20=E5=88=A4=E6=8D=AE?= =?UTF-8?q?=E9=9D=A2=E6=94=B6=E7=AA=84=E4=B8=BA=E3=80=8C=E8=B5=84=E6=BA=90?= =?UTF-8?q?=20+=20=E7=89=88=E6=9C=AC=E3=80=8D=EF=BC=8C=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E8=B5=B7=E5=81=9C=E4=B8=8D=E5=86=8D=E8=A7=A6=E5=8F=91=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E5=8F=AF=E8=A7=81=E6=8B=92=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - projectResourceLiveUpdateModel 新增 manifestCasFingerprint,按 { assets, versions } 判定同版本号冲突 - 同 revision 且判据面一致时,仅非 CAS 账目状态(preview 运行地址/端口、任务进度、项目名)变化改为 accepted 并让内容落地,revision 不推不退 - 判据面变化仍判 revision-conflict,保留「改了被保护内容却没推 revision」的可观测信号,不吞真实缺陷 - 新增回归用例:同 revision 仅预览状态变化必须被接受;同 revision 资产变化必须仍被拒收 - 变异验证:把判据改回整份 JSON 指纹后,新用例以 expected 'revision-conflict' to be 'accepted' 失败 - 定向验证:projectResourceLiveUpdateModel 17 passed、workspaceLauncherManifestMerge 5 passed、appSurface 413 passed、npm run typecheck exit 0、npm run check:encoding 4399 files passed、git diff --check 干净 - 同步 decision-log 与 pitfalls 记录 CAS 判据面契约与本次取证 --- .../projectResourceLiveUpdateModel.ts | 44 +++++++++++++++-- .../projectResourceLiveUpdateModel.test.ts | 49 +++++++++++++++++++ .../shared-memory/decision-log.md | 8 +++ docs/project-memory/shared-memory/pitfalls.md | 9 ++++ 4 files changed, 107 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/projectResourceLiveUpdateModel.ts b/apps/ai-game-creator-shell/src/view/project-development/projectResourceLiveUpdateModel.ts index 8d10f21f2..2ab8dbad2 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/projectResourceLiveUpdateModel.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/projectResourceLiveUpdateModel.ts @@ -27,6 +27,7 @@ export type ProjectManifestMergeState = { projectId: string; revision: number; manifestFingerprint: string; + casFingerprint: string; seenCommitRevisions: string[]; seenEventIds: string[]; }; @@ -51,6 +52,27 @@ function manifestFingerprint(manifest: GameCreationAppManifest) { return JSON.stringify(manifest); } +/** + * CAS 的判据面:**只认资源与版本**。 + * + * 权威契约在 `project/manifest.rs::append_agent_game_iteration_version_at`:revision 是跨 + * 界面排序边界,它保护的对象是「这一版项目用了哪些资源、有哪些正式版本」。清单里还有一类 + * **非 CAS 账目状态**——`preview` 的运行地址与端口、任务进度、项目名。它们由 + * `record_preview_state` / `record_command_run` / `rename_local_game_project` 这类簿记写入 + * 直接落盘而**刻意不推 revision**(预览起停不是源码变更,推 revision 会让运行时的验证凭证 + * 无故漂移)。 + * + * 所以判据面必须与契约一致:拿整份清单 JSON 当判据,会把每一次预览起停都判成「同一版本号上 + * 的清单内容不一致」,用户看到「资源清单更新被拒收」,而真正要紧的新资源与新版本反倒被这条 + * 假冲突挡在画布之外。 + */ +function manifestCasFingerprint(manifest: GameCreationAppManifest) { + return JSON.stringify({ + assets: manifest.assets ?? [], + versions: manifest.versions ?? [], + }); +} + function commitRevisionIdentity(snapshot: ProjectManifestSnapshot) { return snapshot.commitId ? `${snapshot.projectId}:${snapshot.commitId}:${snapshot.revision}` @@ -80,6 +102,7 @@ export function createProjectManifestMergeState( projectId: snapshot.projectId, revision: snapshot.revision, manifestFingerprint: manifestFingerprint(snapshot.manifest), + casFingerprint: manifestCasFingerprint(snapshot.manifest), seenCommitRevisions: [], seenEventIds: [], }, @@ -106,13 +129,27 @@ export function mergeProjectManifestSnapshot( } const fingerprint = manifestFingerprint(snapshot.manifest); + const casFingerprint = manifestCasFingerprint(snapshot.manifest); if (snapshot.revision === current.revision) { - if (fingerprint !== current.manifestFingerprint) { + // 同一版本号上「资源或版本」变了:这是真的 CAS 冲突——有写点改了被保护的内容却没推进 + // revision,必须继续拒收并让用户看见,否则客户端会拿着一份无法定位内容的清单渲染。 + if (casFingerprint !== current.casFingerprint) { return { decision: 'revision-conflict', state: current }; } + if (fingerprint === current.manifestFingerprint) { + return { + decision: 'duplicate', + state: withDedupeIdentities(current, snapshot), + }; + } + // 同一版本号、资源与版本一致,只有非 CAS 账目状态(预览地址/端口、任务进度、项目名) + // 不同:内容必须落地,revision 既不推进也不回退。 return { - decision: 'duplicate', - state: withDedupeIdentities(current, snapshot), + decision: 'accepted', + state: withDedupeIdentities( + { ...current, manifestFingerprint: fingerprint }, + snapshot, + ), }; } @@ -123,6 +160,7 @@ export function mergeProjectManifestSnapshot( ...current, revision: snapshot.revision, manifestFingerprint: fingerprint, + casFingerprint, }, snapshot, ), diff --git a/apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts b/apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts index af4231096..88e13dfaa 100644 --- a/apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts +++ b/apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts @@ -50,6 +50,20 @@ function snapshot( }; } +function snapshotWithManifest( + revision: number, + source: ProjectManifestSnapshot['source'], + value: GameCreationAppManifest, +): ProjectManifestSnapshot { + return { + projectPath: '/tmp/project-live', + projectId: value.projectId, + revision, + manifest: value, + source, + }; +} + function focusIntent( input?: Partial, ): ResourceFocusIntent { @@ -127,6 +141,41 @@ describe('project resource live update model', () => { ).toBe('revision-conflict'); }); + it('adopts same-revision bookkeeping changes instead of rejecting them', () => { + // 预览起停只写 `manifest.preview`(`project/manifest.rs::record_preview_state`)而不推 + // project revision——预览起停不是源码变更。拿整份清单当 CAS 判据,会把每一次预览起停 + // 都变成一条用户可见拒收:「资源清单更新被拒收(同一版本号上的清单内容不一致)」。 + const held = createProjectManifestMergeState( + snapshotWithManifest(4, 'initial', { + ...manifest('project-live'), + preview: { status: 'stopped' }, + }), + ); + const running = snapshotWithManifest(4, 'supervisor', { + ...manifest('project-live'), + preview: { + status: 'running', + url: 'http://127.0.0.1:58144/', + port: 58144, + }, + }); + + const merged = mergeProjectManifestSnapshot(held, running); + expect(merged.decision).toBe('accepted'); + expect(projectManifestMergeRejectionDecision(merged.decision)).toBeNull(); + // revision 既不推进也不回退:账目状态变化本身不是新版本。 + expect(merged.state.revision).toBe(4); + }); + + it('still fails closed when the protected assets change under the same revision', () => { + // 对照钉子:判据面收窄到「资源 + 版本」不等于放过真正的同版本号内容漂移。 + const held = createProjectManifestMergeState(snapshot(4, 'initial', ['art'])); + const divergent = snapshot(4, 'supervisor', ['art', 'smuggled-art']); + expect(mergeProjectManifestSnapshot(held, divergent).decision).toBe( + 'revision-conflict', + ); + }); + it('keeps project switching and user selection generations from stealing focus', () => { expect( resolveResourceFocusIntent( diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index c701ced8f..bbc0ce47c 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8486,3 +8486,11 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 原因:如果读侧沿用栏目 ref,动作会基于最近栏目页的旧缩放/平移值计算,再写入 all;数值碰到缩放边界时用户看到的就是「按钮按了但不动」,且拖动距离也会按错误倍率换算。 - 验证:appSurface 的多栏目「所有资源」用例连续点击两次放大;四处 ref 选择全部变异回旧实现后第二次点击断言红灯,恢复后通过。定向类型检查与 ESLint 均通过。 - 影响范围:`apps/ai-game-creator-shell/src/view/project-development/index.tsx`、`apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts`、`pitfalls.md` 本条。 + +## 2026-09-12 清单 CAS 判据面收窄为「资源 + 版本」:非 CAS 簿记状态不得再触发用户可见拒收 + +- 背景:`manifest.rs::append_agent_game_iteration_version_at` 早已写明「revision 是跨界面排序边界,前端会拒收同 revision 但 **assets 或 versions** 不同的快照」。但前端实现用 `JSON.stringify(manifest)` 整份清单当判据,判据面比契约宽,于是 `record_preview_state` / `record_command_run` / `rename_local_game_project` 这些**刻意不推 revision** 的簿记写入(预览起停、任务进度、项目名)每次落盘都会被判 `revision-conflict`,用户看到「资源清单更新被拒收(同一版本号上的清单内容不一致)」,而真正的新资源与新版本也一起被丢在门外。 +- 决策:CAS 判据面固定为 `{ assets, versions }`(`manifestCasFingerprint`)。同一 revision 下三态——判据面不同 ⇒ `revision-conflict`(真实的"改了被保护内容却没推 revision"必须继续可见,不许被吞掉);判据面相同且整份清单相同 ⇒ `duplicate`;判据面相同、只有非 CAS 账目状态不同 ⇒ `accepted`,revision 不推也不退,内容照常落地到界面。 +- 原因:预览起停不是源码变更,不能靠"给它也推一次 revision"来消除冲突——那会让运行时的验证凭证(`expected_revision` / `verified_revision` / `failed_playtest_revision`)凭空漂移,把自主构建流程拖进无谓的重新验证。正确的修法是把判据面收回到契约所说的对象,而不是让簿记写入承担源码语义。 +- 验证:`projectResourceLiveUpdateModel.test.ts` 新增「同 revision 只有 `preview` 变化必须被接受」与「同 revision 资产变化必须仍被拒收」两条;变异回整份 JSON 指纹后前者以 `expected 'revision-conflict' to be 'accepted'` 失败。定向:模型 17 passed、workspaceLauncherManifestMerge 5 passed、appSurface 413 passed、typecheck exit 0、check:encoding 4399 files passed。 +- 影响范围:`apps/ai-game-creator-shell/src/view/project-development/projectResourceLiveUpdateModel.ts`、`apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts`、`pitfalls.md` 本条。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 01740fb41..d6f01d3ab 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5371,3 +5371,12 @@ - **处理**:所有需要读取当前子画布视口的交互都按目标选择 ref:`resourceBookOpensAllResources` 时读取 `resourceBookAllViewportRef`,栏目页才读取 `resourceCanvasViewportRef`;写回仍统一经过 `setResourceCanvasViewport`,不新增持久化空间。 - **验证**:appSurface 的「所有资源」用例新增连续两次点击放大断言;把四处 all ref 选择变异还原为旧的 `resourceCanvasViewportRef` 后,该用例在第二次点击处红,显示 transform 未变化;恢复后用例通过。 - **关联**:`apps/ai-game-creator-shell/src/view/project-development/index.tsx`、`apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts`、`decision-log.md` 2026-09-12 条。 + +## 2026-09-12 同一版本号上的「簿记写入」被判成 CAS 冲突:判据面比契约宽,每次预览起停都弹「资源清单更新被拒收」 + +- **现象**:真实项目「What do u wanna do kitten」(`C:\Users\dongy\Documents\Genarrative GameAgent\`,项目 revision 74)里反复出现「资源清单更新被拒收(同一版本号上的清单内容不一致),已按磁盘清单重新对齐」;连带表现是新素材/新版本进不了画布,运行模块的版本切换"看起来坏了"(`versions[]` 停在旧快照上),用户前后修过几次都没修掉。 +- **取证(日志 + 磁盘对照,不是推断)**:应用诊断日志 `%APPDATA%\world.genarrative.ai-game-creator\diagnostics\application.log` 记 `[manifest-merge] 清单快照被拒收 decision=revision-conflict source=supervisor snapshotRevision=74 heldRevision=74`(13:17 那次;同页另有十几条 `decision=duplicate`,`duplicate` 不弹提示)。对照项目目录:13:17:17 同一秒写了 `.agent/logs/preview.log` 与 `.agent/manifest.json`,而 `.agent/runtime/project-revision.json` 仍是 `revision: 74`、`updatedAt` 停在 9/11 —— 即「写了清单、没推 revision」。再对照 `App.tsx` 的 supervisor 配对读(`before`/`manifest`/`after` 三段,前后 revision 不等就重试一次),同 revision 却能通过该保护,只剩一种解释:**有写点改了清单内容却没推 revision**。 +- **原因**:`project/manifest.rs::record_preview_state` 只写 `manifest.preview`(运行地址/端口)与 `preview-playtest` 任务状态,**刻意不推 revision**——预览起停不是源码变更,推 revision 会让运行时的验证凭证(`expected_revision` / `verified_revision` / `failed_playtest_revision`)无故漂移。而权威契约(`append_agent_game_iteration_version_at` 的注释)说的是 revision 保护「**资源与版本**」。前端 `manifestFingerprint` 却拿 `JSON.stringify(manifest)` **整份清单**当 CAS 判据 ⇒ 判据面比契约宽:每次预览起停都命中"同版本号、内容不同"⇒ `revision-conflict`;更糟的是这条假冲突把快照丢掉,真正要紧的新资源与新版本也一起被挡在门外。 +- **处理**:把 CAS 判据收窄到契约面——新增 `manifestCasFingerprint`(`{ assets, versions }`),同 revision 下三态:判据面不同 → 仍 `revision-conflict`(真实的漏推 revision 必须继续可见,不许被吞掉);判据面相同且整份清单相同 → `duplicate`;判据面相同、只有非 CAS 账目状态(`preview` / 任务进度 / 项目名)不同 → `accepted`,revision **不推也不退**,让内容真正落地。同类簿记写点(`record_command_run`、`update_manifest_task_status_at`、`rename_local_game_project`、Godot 根校准)同样不推 revision,收窄判据面后不再产生用户可见拒收。 +- **验证**:新增用例 `adopts same-revision bookkeeping changes instead of rejecting them`(同 revision 只有 `preview` 从 stopped 变 running ⇒ 必须 `accepted`、`projectManifestMergeRejectionDecision` 为 null、revision 仍为 4)与对照钉子 `still fails closed when the protected assets change under the same revision`(同 revision 多出一条资产 ⇒ 必须仍 `revision-conflict`)。变异验证:把判据改回整份 JSON 指纹 → 新用例以 `expected 'revision-conflict' to be 'accepted'` 失败(正是用户看到的那条决策),恢复后转绿。定向:`projectResourceLiveUpdateModel` 17 passed、`workspaceLauncherManifestMerge` 5 passed、appSurface 413 passed、`npm run typecheck` exit 0、`npm run check:encoding` 4399 files passed、`git diff --check` 干净。 +- **关联**:`apps/ai-game-creator-shell/src/view/project-development/projectResourceLiveUpdateModel.ts`、`apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx`、`apps/ai-game-creator-shell/src/App.tsx`(supervisor 配对读)、`apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs`、`apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts`。