素材上传不再每次触发一次拒收提示:上传与清单配对读放进同一个函数
Project CI / Repository checks (pull_request) Failing after 1m31s
Project CI / Frontend tests (pull_request) Successful in 3m46s
Project CI / Backend tests (pull_request) Successful in 8m21s
Project CI / Native shell tests (pull_request) Successful in 19m10s

- 原缺陷:上传路径在上传**前**读 get_local_game_project_revision,上传完把那个**旧 revision** 交给 reloadManifestAfterAssetCommand;后者重新读盘拿**新清单**再打上旧 revision。上传本身会推进项目 revision(Rust advance_agent_runtime_project_revision_locked),于是这一对是撕裂的,「同 revision 不同指纹」被 projectResourceLiveUpdateModel 判成 revision-conflict —— 用户每上传一次素材就先看到一条「资源清单更新被拒收…」。
- projectResourceLiveUpdateModel.ts 新增 uploadProjectAssetFilesAndReadSnapshot:上传动作与"配对读"由同一个函数拥有,配对读复用既有 rereadAuthoritativeProjectManifestSnapshot(同一次读里配对 revision + 清单,并再读一次 revision 确认没被写盘插队)。调用方拿不到中间那个 revision,也就没有机会把它贴错。
- index.tsx 的 uploadResourcePanelFiles 改用它,删掉上传前那次 revision 读取与旧 revision 的传递。
- 附带文案误报:describeProjectManifestMergeRejection 的 unresolved 文案由「重新读取磁盘清单失败,请重新打开项目」改为「未能按磁盘清单重新对齐,请重新打开项目」。unresolved 有三条进入路径,其中两条**重读是成功的**(读到的一对比手上旧、或读到的是撕裂的一对),写成"读盘失败"是误报;新文案对所有路径都成立。WorkspaceLauncher.tsx 在"读到了但不采用"那一支补注释说明 stage 不是读失败。
- 断言:projectResourceLiveUpdateModel.test.ts 新增「素材上传后的清单快照必须配对」——假后端在上传时推进 revision,断言 merge 判 accepted 且 projectManifestMergeRejectionDecision 为 null(即"上传一次不产生拒收提示"),并附一条对照钉子显式写出旧形状、断言它必被判 revision-conflict;另新增 unresolved 文案不得包含"读取磁盘清单失败"的用例。
- 变异验证(提交前已跑):把 uploadProjectAssetFilesAndReadSnapshot 还原成"上传前读 revision + 上传后读清单"→ 配对用例变红(并给出 revision-conflict 的失败信息),还原后复跑 15 passed。
- 门禁:projectResourceLiveUpdateModel 15 passed、workspaceLauncherManifestMerge 5 passed。
This commit is contained in:
2026-09-11 21:33:54 +08:00
parent 09ed7b099b
commit 006e9cc2f3
4 changed files with 203 additions and 25 deletions
@@ -172,7 +172,11 @@ export function describeProjectManifestMergeRejection(
if (stage === 'recovered') {
return `资源清单更新被拒收(${cause}),已按磁盘清单重新对齐`;
}
return `资源清单更新被拒收(${cause}),重新读取磁盘清单失败,请重新打开项目`;
// `unresolved` 不等于"读盘失败":重读**可能已经成功**,只是拿到的那一对
// `(revision, 清单)` 按 CAS 判据不能被采用(版本号比手上的旧,或读到的是撕裂的一对)。
// 所以这里说"未能按磁盘清单重新对齐" —— 它对所有进入 `unresolved` 的路径都成立,
// 而"重新读取磁盘清单失败"只对其中一条成立,是一条会误导用户的假陈述。
return `资源清单更新被拒收(${cause}),未能按磁盘清单重新对齐,请重新打开项目`;
}
export type ProjectManifestRereadInput = {
@@ -217,6 +221,59 @@ export async function rereadAuthoritativeProjectManifestSnapshot(
};
}
/**
* 素材上传之后的清单快照:**上传动作与"配对读"必须由同一个函数拥有**。
*
* ⚠️ 不许把「上传**前**读到的 revision」贴在「上传**后**读到的清单」上。上传本身会推进
* 项目 revision(Rust 侧 `advance_agent_runtime_project_revision_locked`),于是这一对是
* 撕裂的:`mergeProjectManifestSnapshot` 看到"同一版本号、内容不同" ⇒ 判 `revision-conflict`
* ⇒ 用户每上传一次素材就先看到一条「资源清单更新被拒收(同一版本号上的清单内容不一致)」。
*
* 唯一正确的形状是 [`rereadAuthoritativeProjectManifestSnapshot`]:它**在同一次读里配对**
* revision 与清单,并再读一次 revision 确认没被写盘插队。把上传与这次配对读放在同一个
* 函数里,是为了让"先读 revision 再上传"这种错配**不可能**再被写出来 —— 调用方拿不到
* 中间那个 revision,也就没有机会把它贴错。
*/
export async function uploadProjectAssetFilesAndReadSnapshot(input: {
projectPath: string;
projectId: string;
commitId: string;
files: readonly {
fileName: string;
mediaType: string;
bytes: number[];
}[];
invoke<T>(command: string, args: Record<string, unknown>): Promise<T>;
}): Promise<ProjectManifestSnapshot | null> {
for (const file of input.files) {
await input.invoke('upload_local_asset', {
projectPath: input.projectPath,
fileName: file.fileName,
mediaType: file.mediaType,
bytes: file.bytes,
});
}
const fresh = await rereadAuthoritativeProjectManifestSnapshot({
projectPath: input.projectPath,
projectId: input.projectId,
readRevision: async () => {
const status = await input.invoke<{ revision: number }>(
'get_local_game_project_revision',
{ projectPath: input.projectPath },
);
return status.revision;
},
readManifest: () =>
input.invoke<GameCreationAppManifest>('get_local_game_manifest', {
projectPath: input.projectPath,
commandId: 'asset.list',
}),
});
return fresh
? { ...fresh, source: 'asset-command', commitId: input.commitId }
: null;
}
export type ResourceFocusIntent = {
flowId: string;
saveAttemptId: string;