From 2e16b8cdeef18c17057438e2b195878812b08006 Mon Sep 17 00:00:00 2001 From: suzmii Date: Mon, 24 Aug 2026 12:34:21 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E7=B4=A0=E6=9D=90=E7=94=BB?= =?UTF-8?q?=E5=B8=83=E5=A4=B1=E8=B4=A5=E4=BA=8B=E5=8A=A1=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐失败结算和归档中间态的崩溃恢复切点 保证候选事实落盘回读后再发布 CandidateReady 同步无限画布合同和共享决策记录 --- .../src/project/asset_canvas/generation.rs | 591 ++++++++++++++++-- .../shared-memory/decision-log.md | 1 + ...客户端素材创作无限画布阶段一合同-2026-08-05.md | 4 +- 3 files changed, 528 insertions(+), 68 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs index f45604b4e..8c8ad923a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs @@ -229,6 +229,7 @@ enum GenerationLedgerPhase { RemoteCompleted, MediaDownloaded, CandidateReady, + AssetCommitSettlementPending, AssetDurableCommitted, FailureSettlementPending, ReconciliationSettlementPending, @@ -249,6 +250,7 @@ impl GenerationLedgerPhase { Self::RemoteCompleted => "remote-completed", Self::MediaDownloaded => "media-downloaded", Self::CandidateReady => "candidate-ready", + Self::AssetCommitSettlementPending => "asset-commit-settlement-pending", Self::AssetDurableCommitted => "asset-durable-committed", Self::FailureSettlementPending => "failure-settlement-pending", Self::ReconciliationSettlementPending => "reconciliation-settlement-pending", @@ -872,6 +874,7 @@ fn public_phase(phase: &GenerationLedgerPhase) -> Option Some(AssetCanvasGenerationStatus::CandidateReady), + GenerationLedgerPhase::AssetCommitSettlementPending => None, GenerationLedgerPhase::AssetDurableCommitted => { Some(AssetCanvasGenerationStatus::AssetDurableCommitted) } @@ -2885,6 +2888,233 @@ fn committed_execution_from_private_result( }) } +fn private_commit_result_from_execution( + execution: &CommitAssetCanvasExecution, +) -> Result { + match &execution.result { + CommitAssetCanvasResult::Committed { + project_id, + project_revision, + committed_project_revision, + draft_revision, + commit_id, + event_id, + asset, + .. + } => Ok(PrivateCommitResult { + resource_id: asset + .source + .resource_id + .clone() + .unwrap_or_else(|| asset.id.clone()), + asset_id: asset.id.clone(), + project_id: project_id.clone(), + commit_id: commit_id.clone(), + committed_project_revision: *committed_project_revision, + draft_revision: *draft_revision, + host_revision: *project_revision, + commit_status: "committed".to_string(), + event_id: event_id.clone(), + }), + CommitAssetCanvasResult::AlreadyCommitted { + project_id, + project_revision, + committed_project_revision, + draft_revision, + commit_id, + event_id, + asset, + .. + } => Ok(PrivateCommitResult { + resource_id: asset + .source + .resource_id + .clone() + .unwrap_or_else(|| asset.id.clone()), + asset_id: asset.id.clone(), + project_id: project_id.clone(), + commit_id: commit_id.clone(), + committed_project_revision: *committed_project_revision, + draft_revision: *draft_revision, + host_revision: *project_revision, + commit_status: "already-committed".to_string(), + event_id: event_id.clone(), + }), + _ => Err("commit-reconciliation-required".to_string()), + } +} + +fn validate_asset_commit_settlement_evidence( + root: &Path, + generation_ledger: &AssetCanvasGenerationLedger, + committed: &PrivateCommitResult, + manifest: &GameCreationAppManifest, +) -> Result<(), String> { + let invalid = || "待提交结算的生成账本 commit 回执与阶段三事务不一致".to_string(); + if committed.project_id != generation_ledger.project_id + || committed.commit_id != generation_ledger.commit_id + || !matches!( + committed.commit_status.as_str(), + "committed" | "already-committed" + ) + || committed.host_revision < committed.committed_project_revision + { + return Err(invalid()); + } + let commit_ledger = + read_asset_canvas_ledger(root, &generation_ledger.commit_id)?.ok_or_else(invalid)?; + let committed_draft_revision = commit_ledger.committed_draft_revision.ok_or_else(invalid)?; + let committed_project_revision = commit_ledger + .committed_project_revision + .ok_or_else(invalid)?; + if commit_ledger.status != AssetCanvasLedgerStatus::Committed + || commit_ledger.project_id != generation_ledger.project_id + || commit_ledger.draft_id != generation_ledger.draft_id + || commit_ledger.commit_id != generation_ledger.commit_id + || commit_ledger.idempotency_key != generation_ledger.commit_idempotency_key + || commit_ledger.expected_project_revision != generation_ledger.expected_host_revision + || committed_project_revision != committed.committed_project_revision + || commit_ledger.expected_project_revision.checked_add(1) + != Some(committed_project_revision) + || committed_draft_revision != committed.draft_revision + || commit_ledger.expected_draft_revision.checked_add(1) != Some(committed_draft_revision) + || commit_ledger.asset_id.as_deref() != Some(committed.asset_id.as_str()) + || commit_ledger.event_id != committed.event_id + { + return Err(invalid()); + } + let journal = + read_asset_canvas_journal(root, &generation_ledger.commit_id)?.ok_or_else(invalid)?; + if !matches!( + journal.stage, + AssetCanvasTransactionStage::Committed | AssetCanvasTransactionStage::EventAttempted + ) || journal.project_id != commit_ledger.project_id + || journal.draft_id != commit_ledger.draft_id + || journal.commit_id != commit_ledger.commit_id + || journal.idempotency_key != commit_ledger.idempotency_key + || journal.request_fingerprint != commit_ledger.request_fingerprint + || journal.expected_project_revision != commit_ledger.expected_project_revision + || journal.target_project_revision != committed_project_revision + || journal.expected_draft_revision != commit_ledger.expected_draft_revision + || journal.target_draft_revision != committed_draft_revision + || journal.asset_id != committed.asset_id + || journal.event_id != committed.event_id + { + return Err(invalid()); + } + let mut assets = manifest + .assets + .iter() + .filter(|asset| asset.id == committed.asset_id); + let asset = assets.next().ok_or_else(invalid)?; + if assets.next().is_some() + || asset + .source + .resource_id + .as_deref() + .unwrap_or(asset.id.as_str()) + != committed.resource_id + { + return Err(invalid()); + } + let event = commit_ledger.event_payload.as_ref().ok_or_else(invalid)?; + if event.project_id != generation_ledger.project_id + || event.draft_id != generation_ledger.draft_id + || event.commit_id != generation_ledger.commit_id + || event.idempotency_key != generation_ledger.commit_idempotency_key + || event.event_id != committed.event_id + || event.committed_project_revision != committed_project_revision + || event.asset != *asset + { + return Err(invalid()); + } + Ok(()) +} + +fn finish_asset_commit_settlement_pending_locked( + root: &Path, + ledger: &mut AssetCanvasGenerationLedger, +) -> Result<(AssetCanvasGenerationRecord, AssetCanvasDraft), String> { + if ledger.phase != GenerationLedgerPhase::AssetCommitSettlementPending { + return Err("素材画布生成账本不在待提交结算阶段".to_string()); + } + let committed = ledger + .commit_result + .clone() + .ok_or_else(|| "待提交结算的生成账本缺少本地 commit 回执".to_string())?; + let manifest = validate_asset_canvas_project_identity(root, &ledger.project_id)?; + validate_asset_commit_settlement_evidence(root, ledger, &committed, &manifest)?; + let draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())?; + let existing = draft + .generations + .iter() + .find(|record| record.generation_id == ledger.generation_id) + .map(|record| { + let matches_private_commit = record.intent_id == ledger.intent_id + && record.output_asset_id.as_deref() == Some(committed.asset_id.as_str()) + && record.error_code.is_none(); + if record.phase == AssetCanvasGenerationStatus::AssetDurableCommitted + && !matches_private_commit + { + return Err("公开提交终态与私有生成账本不一致".to_string()); + } + Ok( + (record.phase == AssetCanvasGenerationStatus::AssetDurableCommitted + && matches_private_commit) + .then(|| record.clone()), + ) + }) + .transpose()? + .flatten(); + let (record, draft_revision) = if let Some(record) = existing { + (record, draft.revision) + } else { + let mut projection = ledger.clone(); + projection.phase = GenerationLedgerPhase::AssetDurableCommitted; + upsert_public_generation_record_locked(root, &projection)? + }; + if ledger.staged_draft_revision.is_some() { + let (metadata, _) = read_staged_image_locked(root, &ledger.staged_image_token)?; + rebind_asset_canvas_staged_image_revision_locked( + root, + &ledger.staged_image_token, + &ledger.project_id, + &ledger.draft_id, + metadata.draft_revision, + draft_revision, + )?; + ledger.staged_draft_revision = Some(draft_revision); + } + ledger.current_draft_revision = Some(draft_revision); + if let Some(commit_result) = ledger.commit_result.as_mut() { + commit_result.draft_revision = draft_revision; + } + ledger.phase = GenerationLedgerPhase::AssetDurableCommitted; + write_generation_ledger(root, ledger)?; + let draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())?; + Ok((record, draft)) +} + +fn finish_asset_commit_settlement_pending_at( + root: &Path, + ledger: &mut AssetCanvasGenerationLedger, + emit: &mut (dyn FnMut(AssetCanvasGenerationProgressEvent) + Send), +) -> Result<(AssetCanvasGenerationRecord, AssetCanvasDraft), String> { + let (record, draft) = { + let _draft_guard = acquire_asset_canvas_draft_lock(root)?; + finish_asset_commit_settlement_pending_locked(root, ledger)? + }; + emit(generation_progress_event( + ledger, + public_phase_name(&record.phase), + public_progress_value(&record.phase), + record.error_code.clone(), + )); + Ok((record, draft)) +} + fn candidate_execution_from_private_result( root: &Path, ledger: &AssetCanvasGenerationLedger, @@ -3095,6 +3325,7 @@ fn settle_generation_failure_at_expected_revision( if matches!( ledger.phase, GenerationLedgerPhase::CandidateReady + | GenerationLedgerPhase::AssetCommitSettlementPending | GenerationLedgerPhase::AssetDurableCommitted | GenerationLedgerPhase::ArchivePending | GenerationLedgerPhase::Archived @@ -3378,6 +3609,10 @@ async fn reconcile_generation( if ledger.phase == GenerationLedgerPhase::CandidateReady { return candidate_execution_from_private_result(root, &ledger); } + if ledger.phase == GenerationLedgerPhase::AssetCommitSettlementPending { + finish_asset_commit_settlement_pending_at(root, &mut ledger, emit)?; + return committed_execution_from_private_result(root, &ledger); + } if ledger.phase == GenerationLedgerPhase::AssetDurableCommitted { return committed_execution_from_private_result(root, &ledger); } @@ -3877,64 +4112,11 @@ async fn reconcile_generation( return Err(sanitized_generation_error("commit-reconciliation-required")); } }; - let private_commit = match &execution.result { - CommitAssetCanvasResult::Committed { - project_id, - project_revision, - committed_project_revision, - draft_revision, - commit_id, - event_id, - asset, - .. - } => PrivateCommitResult { - resource_id: asset - .source - .resource_id - .clone() - .unwrap_or_else(|| asset.id.clone()), - asset_id: asset.id.clone(), - project_id: project_id.clone(), - commit_id: commit_id.clone(), - committed_project_revision: *committed_project_revision, - draft_revision: *draft_revision, - host_revision: *project_revision, - commit_status: "committed".to_string(), - event_id: event_id.clone(), - }, - CommitAssetCanvasResult::AlreadyCommitted { - project_id, - project_revision, - committed_project_revision, - draft_revision, - commit_id, - event_id, - asset, - .. - } => PrivateCommitResult { - resource_id: asset - .source - .resource_id - .clone() - .unwrap_or_else(|| asset.id.clone()), - asset_id: asset.id.clone(), - project_id: project_id.clone(), - commit_id: commit_id.clone(), - committed_project_revision: *committed_project_revision, - draft_revision: *draft_revision, - host_revision: *project_revision, - commit_status: "already-committed".to_string(), - event_id: event_id.clone(), - }, - _ => { - mark_generation_error( - root, - &mut ledger, - true, - "commit-reconciliation-required", - emit, - )?; - return Err(sanitized_generation_error("commit-reconciliation-required")); + let private_commit = match private_commit_result_from_execution(&execution) { + Ok(commit) => commit, + Err(code) => { + mark_generation_error(root, &mut ledger, true, &code, emit)?; + return Err(sanitized_generation_error(&code)); } }; ledger.current_draft_revision = Some(private_commit.draft_revision); @@ -3942,17 +4124,14 @@ async fn reconcile_generation( set_private_phase( root, &mut ledger, - GenerationLedgerPhase::AssetDurableCommitted, + GenerationLedgerPhase::AssetCommitSettlementPending, None, )?; - let generation = publish_public_phase(root, &mut ledger, emit)?; + let (generation, committed_draft) = + finish_asset_commit_settlement_pending_at(root, &mut ledger, emit)?; let final_draft_revision = ledger .current_draft_revision .expect("public commit projection must advance the draft revision"); - if let Some(committed) = ledger.commit_result.as_mut() { - committed.draft_revision = final_draft_revision; - } - write_generation_ledger(root, &mut ledger)?; let committed = ledger .commit_result .as_ref() @@ -3962,8 +4141,7 @@ async fn reconcile_generation( result: GenerateAssetCanvasImageResult { generation, images: Vec::new(), - draft: read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? - .ok_or_else(|| "素材画布草稿不存在".to_string())?, + draft: committed_draft, commit: Some(AssetCanvasGenerationCommitResult { resource_id: committed.resource_id.clone(), asset_id: committed.asset_id.clone(), @@ -4291,6 +4469,12 @@ pub(crate) async fn recover_asset_canvas_generations_at( let _ = finish_archive_pending_at(root, &mut ledger); continue; } + if ledger.phase == GenerationLedgerPhase::AssetCommitSettlementPending { + let _guard = + generation_singleflight_lock(&input.expected_project_id, &generation_id).await; + finish_asset_commit_settlement_pending_at(root, &mut ledger, &mut emit)?; + continue; + } if pending_failure_settlement_target(&ledger.phase).is_some() { let _guard = generation_singleflight_lock(&input.expected_project_id, &generation_id).await; @@ -4755,6 +4939,89 @@ mod tests { } } + fn prepare_asset_commit_settlement_pending_fixture( + project_id: &str, + project_name: &str, + ) -> ( + tempfile::TempDir, + AssetCanvasDraft, + GenerateAssetCanvasImageInput, + AssetCanvasGenerationLedger, + u64, + ) { + let (directory, draft) = create_generation_fixture(project_id, project_name); + let input = generation_input( + directory.path(), + project_id, + &draft, + "验证正式资源提交结算可以跨重启恢复", + ); + let mut ledger = validate_and_prepare_ledger(directory.path(), &input, "", None) + .expect("prepare generation"); + let staged = stage_asset_canvas_image_with_token_at( + directory.path(), + &StageAssetCanvasImageInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id.clone(), + expected_draft_revision: draft.revision, + media_type: "image/png".to_string(), + bytes: test_png_with_color([82, 116, 210, 255]), + }, + Some(&ledger.staged_image_token), + ) + .expect("stage generated image before commit"); + ledger.current_draft_revision = Some(staged.draft_revision); + ledger.staged_draft_revision = Some(staged.draft_revision); + set_private_phase( + directory.path(), + &mut ledger, + GenerationLedgerPhase::MediaDownloaded, + None, + ) + .expect("persist downloaded phase"); + publish_public_phase(directory.path(), &mut ledger, &mut |_| {}) + .expect("publish downloaded phase"); + let media_draft_revision = ledger + .current_draft_revision + .expect("downloaded projection advances draft revision"); + let execution = commit_asset_canvas_at( + directory.path(), + &CommitAssetCanvasInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + expected_revision: ledger.expected_host_revision, + expected_draft_revision: media_draft_revision, + draft_id: draft.draft_id.clone(), + commit_id: ledger.commit_id.clone(), + idempotency_key: ledger.commit_idempotency_key.clone(), + intent: ledger.intent.clone(), + source_asset_id: ledger.source_asset_id.clone(), + staged_image_token: ledger.staged_image_token.clone(), + source_layer_id: None, + media_sha256: None, + name: ledger.asset_name.clone(), + asset_kind: ledger.asset_kind.clone(), + reference_resource_ids: ledger.requested_reference_resource_ids.clone(), + generation_provenance: None, + }, + ) + .expect("commit generated image before settlement"); + let private_commit = + private_commit_result_from_execution(&execution).expect("read private commit result"); + let committed_draft_revision = private_commit.draft_revision; + ledger.current_draft_revision = Some(committed_draft_revision); + ledger.commit_result = Some(private_commit); + set_private_phase( + directory.path(), + &mut ledger, + GenerationLedgerPhase::AssetCommitSettlementPending, + None, + ) + .expect("persist asset commit settlement journal"); + (directory, draft, input, ledger, committed_draft_revision) + } + fn accepted_ledger( project_id: &str, draft: &AssetCanvasDraft, @@ -6011,6 +6278,198 @@ mod tests { assert_eq!(progress[0].phase, "failed"); } + #[tokio::test] + async fn pending_asset_commit_settlement_recovers_across_all_durable_write_cuts() { + for cut in ["before-public", "after-public", "after-staging"] { + let project_id = format!("asset-commit-settlement-{cut}"); + let (directory, draft, input, ledger, committed_draft_revision) = + prepare_asset_commit_settlement_pending_fixture( + &project_id, + "正式资源提交 pending 恢复测试", + ); + let interrupted_draft = + read_asset_canvas_draft_locked(directory.path(), &project_id, &draft.draft_id) + .expect("read interrupted commit draft") + .expect("interrupted commit draft exists"); + assert_eq!(interrupted_draft.revision, committed_draft_revision); + assert!(interrupted_draft.generations.iter().any(|record| { + record.generation_id == input.generation_id + && record.phase == AssetCanvasGenerationStatus::MediaDownloaded + && record.output_asset_id.is_none() + })); + + let mut projected_revision = committed_draft_revision + 1; + if cut != "before-public" { + let mut projection = ledger.clone(); + projection.phase = GenerationLedgerPhase::AssetDurableCommitted; + let (_, revision) = upsert_public_generation_record(directory.path(), &projection) + .expect("simulate public commit projection before terminal ledger write"); + projected_revision = revision; + } + if cut == "after-staging" { + let (metadata, _) = + read_staged_image_locked(directory.path(), &ledger.staged_image_token) + .expect("read staging before simulated rebind"); + rebind_asset_canvas_staged_image_revision_at( + directory.path(), + &ledger.staged_image_token, + &project_id, + &draft.draft_id, + metadata.draft_revision, + projected_revision, + ) + .expect("simulate staging revision before terminal ledger write"); + } + + let mut progress = Vec::new(); + let recovered = recover_asset_canvas_generations_at( + directory.path(), + &RecoverAssetCanvasGenerationsInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.clone(), + draft_id: draft.draft_id.clone(), + }, + |event| progress.push(event), + ) + .await + .expect("recover pending asset commit settlement"); + assert!(recovered.result.resumed_generation_ids.is_empty()); + assert!(recovered.result.service_identity_confirmations.is_empty()); + let recovered_ledger = read_generation_ledger(directory.path(), &input.generation_id) + .expect("read recovered commit ledger") + .expect("recovered commit ledger exists"); + assert_eq!( + recovered_ledger.phase, + GenerationLedgerPhase::AssetDurableCommitted + ); + assert_eq!( + recovered_ledger.current_draft_revision, + Some(projected_revision) + ); + assert_eq!( + recovered_ledger.staged_draft_revision, + Some(projected_revision) + ); + assert_eq!( + recovered_ledger + .commit_result + .as_ref() + .expect("recovered commit result") + .draft_revision, + projected_revision + ); + let recovered_draft = + read_asset_canvas_draft_locked(directory.path(), &project_id, &draft.draft_id) + .expect("read recovered commit draft") + .expect("recovered commit draft exists"); + assert_eq!(recovered_draft.revision, projected_revision); + assert_eq!( + recovered_draft + .generations + .iter() + .filter(|record| record.generation_id == input.generation_id) + .count(), + 1 + ); + let recovered_generation = recovered_draft + .generations + .iter() + .find(|record| record.generation_id == input.generation_id) + .expect("recovered public generation"); + assert_eq!( + recovered_generation.phase, + AssetCanvasGenerationStatus::AssetDurableCommitted + ); + assert_eq!( + recovered_generation.output_asset_id.as_deref(), + recovered_ledger + .commit_result + .as_ref() + .map(|result| result.asset_id.as_str()) + ); + let (recovered_staging, _) = + read_staged_image_locked(directory.path(), &ledger.staged_image_token) + .expect("read recovered commit staging"); + assert_eq!(recovered_staging.draft_revision, projected_revision); + assert_eq!(progress.len(), 1); + assert_eq!(progress[0].phase, "asset-durable-committed"); + } + } + + #[tokio::test] + async fn pending_asset_commit_settlement_rejects_mismatched_commit_evidence() { + for mismatch in ["private-receipt", "commit-ledger", "transaction-journal"] { + let project_id = format!("asset-commit-evidence-{mismatch}"); + let (directory, draft, input, mut generation_ledger, _) = + prepare_asset_commit_settlement_pending_fixture( + &project_id, + "正式资源提交证据错配测试", + ); + match mismatch { + "private-receipt" => { + generation_ledger + .commit_result + .as_mut() + .expect("pending commit receipt") + .commit_id = Uuid::new_v4().to_string(); + write_generation_ledger(directory.path(), &mut generation_ledger) + .expect("persist mismatched private receipt"); + } + "commit-ledger" => { + let mut commit_ledger = + read_asset_canvas_ledger(directory.path(), &generation_ledger.commit_id) + .expect("read stage-three commit ledger") + .expect("stage-three commit ledger exists"); + commit_ledger.idempotency_key = Uuid::new_v4().to_string(); + write_asset_canvas_ledger(directory.path(), &commit_ledger) + .expect("persist mismatched stage-three commit ledger"); + } + "transaction-journal" => { + let mut journal = + read_asset_canvas_journal(directory.path(), &generation_ledger.commit_id) + .expect("read stage-three transaction journal") + .expect("stage-three transaction journal exists"); + journal.asset_id = "mismatched-asset".to_string(); + write_asset_canvas_journal(directory.path(), &journal) + .expect("persist mismatched stage-three transaction journal"); + } + _ => unreachable!(), + } + + let error = match recover_asset_canvas_generations_at( + directory.path(), + &RecoverAssetCanvasGenerationsInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.clone(), + draft_id: draft.draft_id.clone(), + }, + |_| {}, + ) + .await + { + Ok(_) => panic!("mismatched commit evidence must fail closed"), + Err(error) => error, + }; + assert!(error.contains("commit 回执与阶段三事务不一致")); + let retained_ledger = read_generation_ledger(directory.path(), &input.generation_id) + .expect("read retained generation ledger") + .expect("retained generation ledger exists"); + assert_eq!( + retained_ledger.phase, + GenerationLedgerPhase::AssetCommitSettlementPending + ); + let retained_draft = + read_asset_canvas_draft_locked(directory.path(), &project_id, &draft.draft_id) + .expect("read retained draft") + .expect("retained draft exists"); + assert!(retained_draft.generations.iter().any(|record| { + record.generation_id == input.generation_id + && record.phase == AssetCanvasGenerationStatus::MediaDownloaded + && record.output_asset_id.is_none() + })); + } + } + #[tokio::test] async fn failed_generation_archive_is_revision_safe_durable_and_idempotent() { let project_id = "asset-canvas-failed-archive-project"; diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f366b28b2..bbdc3f08d 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -14436,3 +14436,4 @@ - Direct 恢复 claim:同一 App 实例重复水合相同 stable turn 并收到“仍在运行”时,必须释放该 `projectPath + clientTurnId` 的恢复 claim,且不得写稳定 assistant 终态。后续显式刷新对话可按原身份重新读取或续跑;不新增无界自动重试。 - 严格图集崩溃收口:workflow 在严格图集调用前先持久化 `strictSpritesheetPending` 并冻结底层严格事务覆盖的九项旧合同身份;旧路径可精确冻结为缺失。Provider 完成结果先绑定原 retained stage ledger。恢复在同一项目锁内对账严格事务;只有新九项合同、规范图/背景图替换锚点与 retained spritesheet result 三者一致才补写 `completed`,旧九项合同才允许补偿。旧合同判定、写 `compensating`、恢复两项素材与登记、回读和清锚点必须在同一项目锁内,重启已有 `compensating` 也重新判定;第三种混合、漂移或 foreign result 状态进入 reconciliation。不能在主图集与四切片已整体提交后仍按两文件 rollback 制造混合包;若中断前阶段告警尚未进入 durable completed result,恢复结果追加“原阶段告警无法完整重放”的明确 warning,不静默清空。 - Direct 对话恢复从新到旧扫描全部合法 User 回合,遇到较新已回答回合继续向前,不得丢失更早未回答回合。成功返回时 Rust 已先持久化 assistant,前端冗余 append 失败也不得重跑 Provider;普通错误终态的显式 append 失败后,恢复 claim 必须保持到 React fallback writer 对同一稳定 assistant messageId 的写入明确成功或失败,不能在 writer 尚在途时按旧 `/history` 快照重跑。fallback 成功后释放 claim;fallback 失败时跳过该 writer 的无界迟到重试并释放 claim,后续显式 `/history` 才可复用原稳定 `clientTurnId`。终态收敛后删除 claim,避免长会话无界增长。 +- 正式资源提交结算遵守同一顺序:阶段三 commit 成功后先持久化 `asset-commit-settlement-pending`,恢复器幂等补齐 `asset-durable-committed` 公开投影与 staging revision,再发布私有终态;公开投影已经存在时不得重复增加草稿 revision。恢复必须把私有回执与阶段三 commit ledger、transaction journal、manifest 资产和事件 payload 的完整身份绑定,任一错配都保留 pending 并失败关闭。回归同时覆盖三个 durable write cut,以及私有回执、commit ledger、journal 错配。 diff --git a/docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md b/docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md index 8d3f8d8c0..468cb93bf 100644 --- a/docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md +++ b/docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md @@ -957,7 +957,7 @@ confirmation-required - `generation-accepted` 只表示 External `202 + operationId` 已原子落账;`queued/running` 继续属于远端生成期。 - `remote-completed` 必须已经取得自洽稳定引用;缺 `objectKey/resourceId/assetObjectId` 中可支持下载与身份核验的必要组合时进入 `reconciliation-required`,不得显示成功。 - `media-downloaded` 表示媒体通过 `/assets/read-url` 换签、SSRF/重定向门禁、大小、签名、完整解码和尺寸检查,并已写入阶段三受控 staging;重复 completed 不再下载或重写。 -- `asset-durable-committed` 只来自阶段三 journal / ledger 的 `committed | already-committed`。下载成功但提交失败时保留 generation ledger、staging token 和原 commit 身份,恢复只继续本地事务,不重新生成或下载。 +- `asset-durable-committed` 只来自阶段三 journal / ledger 的 `committed | already-committed`。正式资源事务成功后,私有生成账本必须先进入 `asset-commit-settlement-pending`,再幂等补齐公开 generation 投影和 staging revision,最后才发布 `asset-durable-committed`;任一持久化切点重启都不得重复推进草稿 revision。恢复投影前必须逐项绑定私有 commit 回执、阶段三 commit ledger、transaction journal、manifest 资产与事件 payload;任一项目、草稿、commit、幂等键、revision、资产、资源或事件身份错配都保持 pending 并失败关闭。下载成功但提交失败时保留 generation ledger、staging token 和原 commit 身份,恢复只继续本地事务,不重新生成或下载。 - `manifest-projected / layout-ready / selected` 继续复用阶段四单向链路。只有当前项目、flow、session、draft、intent 和 focus generation 全匹配时才允许选择;manifest 已提交但事件丢失时以 command/recovery 的权威完整快照继续投影。 ### 14.4 首版请求范围与恢复 @@ -1002,7 +1002,7 @@ confirmation-required - 所有编辑结果固定创建新的本地 asset、文件路径、版本 ID 或资源身份。源 asset、源文件、Agent 回执与源版本不得删除、覆盖或复用;新 asset 的 `referenceResourceIds` 必须包含源资源规范身份,资源总览同时保留新旧资源。 - 各类型统一使用稳定 `operationId / Idempotency-Key`。普通站内响应的 queued operation 与高级 External v1 的 `202` 都只表示受理,必须轮询原 operation;inline 完成则直接消费同一请求的稳定结果。提交结果未知或登录失效时保留原身份供恢复,不能换键重提。签名 URL 不得写入 manifest,manifest 只保存稳定 objectKey 对应的资源 / 资产身份。 - 图片 refine、视频、音效和背景音乐请求统一在 `generationInputs.source` 写入专用消费身份 `game-creator-resource-editor`;普通内部路由和高级 External 路由都必须实际读取并传递同一稳定 `Idempotency-Key`。队列完成态只向该消费身份返回经过裁剪的稳定 `objectKey / resource / asset` 引用和必要媒体元数据,不暴露 provider、worker、队列内部字段或临时签名 URL。 -- 生成公开状态每次写入草稿都必须推进草稿 revision,并把最新 revision 同步到私有生成账本、进度事件、staging 与正式 commit;旧 UI 快照不得覆盖 accepted/running/reconciliation 状态。 +- 生成公开状态每次写入草稿都必须推进草稿 revision,并把最新 revision 同步到私有生成账本、进度事件、staging 与正式 commit;旧 UI 快照不得覆盖 accepted/running/reconciliation 状态。正式 commit 已成功但公开终态尚未写入时,恢复扫描必须在无需远端凭据的情况下完成 `asset-commit-settlement-pending`,不能把私有 committed 账本当作已经完成公开投影。 - operation 已受理或首次提交结果未知后遇到 `401`,不得写成 terminal failed 或不可逆 `reconciliation-required`。普通模式先按上述单飞流程刷新;刷新失败、换号或当前 owner 不匹配时保留原 remote phase、operation、原请求字节和原幂等身份,停止网络。重新登录同一 owner 后,已有 `operationId` 只继续 GET 同一 operation,未受理请求才允许用冻结正文和原幂等键重试 POST。高级 External v1 模式修正 Developer API Key 后遵守相同恢复规则。 - 全类型资源编辑私有账本必须冻结 `sourceAssetId / sourceMediaType / sourceAssetKind / producerTaskId / sourceVersionId` 源快照;新账本不得依赖 UI 仍停留在原资源,旧账本则只允许从当前 manifest、已完成任务产物、版本数组和受控扩展名兼容恢复。恢复结果仍要重新校验唯一来源、项目归属与媒体类型,不能把账本快照当成越权凭据。 - 普通模式私有账本绑定固定官方 origin 与 ownerUserId,不绑定可轮换的 Access Token;恢复命令不得让前端重新拼请求或把 Token 写入参数。高级 External v1 账本绑定显式服务 origin,同样不绑定可轮换的 Developer API Key。两种模式都不得因凭据轮换改变原 operation;`401/403/404`、轮询超时或网关暂时失败必须保留原 endpoint、请求字节、幂等键和 operation,并先按账本记录的模式与 owner 做隔离校验。