修复清单快照误判:CAS 判据面收窄为「资源 + 版本」,预览起停不再触发用户可见拒收

- 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 判据面契约与本次取证
This commit is contained in:
2026-09-12 13:30:18 +08:00
parent 38cd688d71
commit 028b20a590
4 changed files with 107 additions and 3 deletions
@@ -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,
),
@@ -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>,
): 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(
@@ -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` 本条。
@@ -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`