From fb12ef27c6ece7a3aa58b6ab383d75e698520083 Mon Sep 17 00:00:00 2001 From: suzmii Date: Sun, 23 Aug 2026 19:10:08 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E7=94=9F=E6=88=90=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E7=9A=84=E5=8F=AF=E9=87=8D=E6=94=BE=E7=BB=93=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生成流程内部错误统一先写 pending ledger,再更新公开草稿、staging revision 与终态账本。 增加 pending 写入后故障注入恢复回归。 补齐公开草稿、staging 与终态账本之间三个持久化切点测试。 同步更新失败结算技术合同与共享决策记录。 --- .../src/project/asset_canvas/generation.rs | 251 +++++++++++++++++- .../shared-memory/decision-log.md | 4 +- ...】GameAgent资源自由画板与快速编辑-2026-08-20.md | 2 +- 3 files changed, 242 insertions(+), 15 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 4ffabe350..b8b63d396 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 @@ -2805,22 +2805,52 @@ fn mark_generation_error( reconciliation: bool, error_code: &str, emit: &mut (dyn FnMut(AssetCanvasGenerationProgressEvent) + Send), +) -> Result<(), String> { + mark_generation_error_with_pending_hook(root, ledger, reconciliation, error_code, emit, || { + Ok(()) + }) +} + +fn mark_generation_error_with_pending_hook( + root: &Path, + ledger: &mut AssetCanvasGenerationLedger, + reconciliation: bool, + error_code: &str, + emit: &mut (dyn FnMut(AssetCanvasGenerationProgressEvent) + Send), + after_pending: impl FnOnce() -> Result<(), String>, ) -> Result<(), String> { // Cancellation is authoritative and must not be overwritten by a late // network/commit error. The private ledger keeps its last durable phase as // evidence while all public projection and candidate persistence stop. - ensure_generation_draft_active(root, ledger)?; - set_private_phase( - root, - ledger, - if reconciliation { - GenerationLedgerPhase::ReconciliationRequired + let (_, record) = { + let _draft_guard = acquire_asset_canvas_draft_lock(root)?; + if pending_failure_settlement_target(&ledger.phase).is_some() { + finish_generation_failure_settlement_pending_locked(root, ledger)? } else { - GenerationLedgerPhase::Failed - }, - Some(error_code), - )?; - publish_public_phase(root, ledger, emit).map(|_| ()) + let draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())?; + validate_generation_draft_active(ledger, &draft)?; + let reconciliation = + reconciliation || ledger.phase == GenerationLedgerPhase::ReconciliationRequired; + ledger.phase = if reconciliation { + GenerationLedgerPhase::ReconciliationSettlementPending + } else { + GenerationLedgerPhase::FailureSettlementPending + }; + ledger.error_code = Some(error_code.to_string()); + ledger.current_draft_revision = Some(draft.revision); + write_generation_ledger(root, ledger)?; + after_pending()?; + finish_generation_failure_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(()) } fn pending_failure_settlement_target( @@ -5417,7 +5447,87 @@ mod tests { } #[tokio::test] - async fn pending_generation_failure_settlement_recovers_across_both_durable_write_cuts() { + async fn internal_generation_error_recovers_after_pending_ledger_cut() { + let project_id = "asset-canvas-internal-error-pending"; + let (directory, draft) = create_generation_fixture(project_id, "内部失败 pending 恢复测试"); + let input = generation_input( + directory.path(), + project_id, + &draft, + "恢复生成流程内部失败的 pending 结算", + ); + let mut ledger = + validate_and_prepare_ledger(directory.path(), &input).expect("prepare generation"); + set_private_phase( + directory.path(), + &mut ledger, + GenerationLedgerPhase::Running, + None, + ) + .expect("persist running phase"); + publish_public_phase(directory.path(), &mut ledger, &mut |_| {}) + .expect("publish running phase"); + let running_draft = + read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) + .expect("read running draft") + .expect("running draft exists"); + + let error = mark_generation_error_with_pending_hook( + directory.path(), + &mut ledger, + false, + "generation-rejected", + &mut |_| {}, + || Err("fault-injected:failure-settlement-pending".to_string()), + ) + .expect_err("fault after pending ledger must interrupt settlement"); + assert_eq!(error, "fault-injected:failure-settlement-pending"); + let interrupted_ledger = read_generation_ledger(directory.path(), &input.generation_id) + .expect("read interrupted internal failure ledger") + .expect("interrupted internal failure ledger exists"); + assert_eq!( + interrupted_ledger.phase, + GenerationLedgerPhase::FailureSettlementPending + ); + let interrupted_draft = + read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) + .expect("read interrupted internal failure draft") + .expect("interrupted internal failure draft exists"); + assert_eq!(interrupted_draft.revision, running_draft.revision); + assert!(interrupted_draft.generations.iter().any(|record| { + record.generation_id == input.generation_id + && record.phase == AssetCanvasGenerationStatus::GenerationRunning + })); + + recover_asset_canvas_generations_at( + directory.path(), + &RecoverAssetCanvasGenerationsInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id.clone(), + }, + |_| {}, + ) + .await + .expect("recover internal generation error settlement"); + let recovered_ledger = read_generation_ledger(directory.path(), &input.generation_id) + .expect("read recovered internal failure ledger") + .expect("recovered internal failure ledger exists"); + assert_eq!(recovered_ledger.phase, GenerationLedgerPhase::Failed); + let recovered_draft = + read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) + .expect("read recovered internal failure draft") + .expect("recovered internal failure draft exists"); + assert_eq!(recovered_draft.revision, running_draft.revision + 1); + assert!(recovered_draft.generations.iter().any(|record| { + record.generation_id == input.generation_id + && record.phase == AssetCanvasGenerationStatus::Failed + && record.error_code.as_deref() == Some("generation-rejected") + })); + } + + #[tokio::test] + async fn pending_generation_failure_settlement_recovers_across_all_durable_write_cuts() { let project_id = "asset-canvas-failure-pending-before-public"; let (directory, draft) = create_generation_fixture(project_id, "失败结算 pending 恢复测试"); let input = generation_input( @@ -5500,9 +5610,24 @@ mod tests { read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) .expect("read running draft") .expect("running draft exists"); + 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: running_draft.revision, + media_type: "image/png".to_string(), + bytes: test_png(), + }, + Some(&ledger.staged_image_token), + ) + .expect("stage reconciliation candidate"); + assert_eq!(staged.status, "staged"); ledger.phase = GenerationLedgerPhase::ReconciliationSettlementPending; ledger.error_code = Some("poll-result-unknown".to_string()); ledger.current_draft_revision = Some(running_draft.revision); + ledger.staged_draft_revision = Some(running_draft.revision); write_generation_ledger(directory.path(), &mut ledger) .expect("persist reconciliation settlement journal"); let mut projection = ledger.clone(); @@ -5510,6 +5635,13 @@ mod tests { let (_, projected_revision) = upsert_public_generation_record(directory.path(), &projection) .expect("simulate public projection before terminal ledger write"); + let (staged_before_recovery, _) = + read_staged_image_locked(directory.path(), &ledger.staged_image_token) + .expect("read staging before reconciliation recovery"); + assert_eq!( + staged_before_recovery.draft_revision, + running_draft.revision + ); let mut progress = Vec::new(); recover_asset_canvas_generations_at( @@ -5535,6 +5667,10 @@ mod tests { .expect("read recovered reconciliation draft") .expect("recovered reconciliation draft exists"); assert_eq!(recovered_draft.revision, projected_revision); + let (recovered_staging, _) = + read_staged_image_locked(directory.path(), &ledger.staged_image_token) + .expect("read rebound staging after reconciliation recovery"); + assert_eq!(recovered_staging.draft_revision, projected_revision); assert!(recovered_draft.generations.iter().any(|record| { record.generation_id == input.generation_id && record.phase == AssetCanvasGenerationStatus::ReconciliationRequired @@ -5542,6 +5678,97 @@ mod tests { })); assert_eq!(progress.len(), 1); assert_eq!(progress[0].phase, "reconciliation-required"); + + let project_id = "asset-canvas-failure-pending-after-staging"; + let (directory, draft) = + create_generation_fixture(project_id, "失败结算 staging 后恢复测试"); + let input = generation_input( + directory.path(), + project_id, + &draft, + "恢复已推进 staging 但未写终态账本的失败结算", + ); + let mut ledger = + validate_and_prepare_ledger(directory.path(), &input).expect("prepare generation"); + set_private_phase( + directory.path(), + &mut ledger, + GenerationLedgerPhase::Running, + None, + ) + .expect("persist running phase"); + publish_public_phase(directory.path(), &mut ledger, &mut |_| {}) + .expect("publish running phase"); + let running_draft = + read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) + .expect("read running draft") + .expect("running draft exists"); + 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: running_draft.revision, + media_type: "image/png".to_string(), + bytes: test_png_with_color([22, 44, 66, 255]), + }, + Some(&ledger.staged_image_token), + ) + .expect("stage failure candidate"); + assert_eq!(staged.status, "staged"); + ledger.phase = GenerationLedgerPhase::FailureSettlementPending; + ledger.error_code = Some("generation-rejected".to_string()); + ledger.current_draft_revision = Some(running_draft.revision); + ledger.staged_draft_revision = Some(running_draft.revision); + write_generation_ledger(directory.path(), &mut ledger) + .expect("persist failure settlement journal"); + let mut projection = ledger.clone(); + projection.phase = GenerationLedgerPhase::Failed; + let (_, projected_revision) = + upsert_public_generation_record(directory.path(), &projection) + .expect("simulate failure public projection before staging revision"); + rebind_asset_canvas_staged_image_revision_at( + directory.path(), + &ledger.staged_image_token, + project_id, + &draft.draft_id, + running_draft.revision, + projected_revision, + ) + .expect("simulate staging revision before terminal ledger write"); + + let mut progress = Vec::new(); + recover_asset_canvas_generations_at( + directory.path(), + &RecoverAssetCanvasGenerationsInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id.clone(), + }, + |event| progress.push(event), + ) + .await + .expect("recover pending failure after staging revision"); + let recovered_ledger = read_generation_ledger(directory.path(), &input.generation_id) + .expect("read recovered failure ledger") + .expect("recovered failure ledger exists"); + assert_eq!(recovered_ledger.phase, GenerationLedgerPhase::Failed); + assert_eq!( + recovered_ledger.staged_draft_revision, + Some(projected_revision) + ); + let recovered_draft = + read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) + .expect("read recovered failure draft") + .expect("recovered failure draft exists"); + assert_eq!(recovered_draft.revision, projected_revision); + let (recovered_staging, _) = + read_staged_image_locked(directory.path(), &ledger.staged_image_token) + .expect("read staging after terminal ledger recovery"); + assert_eq!(recovered_staging.draft_revision, projected_revision); + assert_eq!(progress.len(), 1); + assert_eq!(progress[0].phase, "failed"); } #[tokio::test] diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index c521cdf01..064f0d72c 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -14395,6 +14395,6 @@ ## 2026-08-23 AGC 素材画布失败结算使用可重放中间态 -- 失败结算的 `expectedDraftRevision` CAS、结算意图和公开投影必须在同一草稿锁边界内串行化;CAS 成功后先持久化 `failure-settlement-pending` 或 `reconciliation-settlement-pending`,不能直接跨文件发布终态。 +- 失败结算的 `expectedDraftRevision` CAS、结算意图和公开投影必须在同一草稿锁边界内串行化;显式失败结算和生成流程内部错误都先持久化 `failure-settlement-pending` 或 `reconciliation-settlement-pending`,不能直接跨文件发布终态。 - pending 恢复不依赖平台登录或 External API Key。恢复按当前权威草稿幂等补齐 generation 投影和 staging revision,再把私有 ledger 发布为 `failed` 或 `reconciliation-required`;公开投影已经存在时只完成账本,不重复增加草稿 revision。 -- 回归必须覆盖 pending ledger 写入后、公开草稿写入前,以及公开草稿写入后、终态 ledger 写入前两种重启切点。 +- 回归必须覆盖 pending ledger 写入后、公开草稿写入前,公开草稿写入后、staging revision 写入前,以及 staging revision 写入后、终态 ledger 写入前三种重启切点。 diff --git a/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md b/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md index 74ec4eb99..0dcb25495 100644 --- a/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md +++ b/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md @@ -92,7 +92,7 @@ - 生成失败时,公开 generation 记录必须在同一草稿 revision 链路中落为 `failed` 或 `reconciliation-required`,不得留下持久化的 `generation-running` 幽灵任务。 - 客户端处理生成失败时必须调用宿主的权威失败结算接口并携带 `expectedDraftRevision`;结算返回 revision 冲突或失败时保持失败/对账阻断态,不得把画布静默恢复为 clean editing。无私有账本的早期失败只允许由后端原子移除公开占位并返回 `not-started`,已进入候选或正式提交终态的结果返回 `already-terminal`,等待重新打开对账。 -- 权威失败结算先将私有 ledger 写为 `failure-settlement-pending` 或 `reconciliation-settlement-pending`,再幂等更新公开草稿与 staging revision,最后发布 `failed` 或 `reconciliation-required`。任一持久化边界中断后,启动恢复必须在解析生成服务凭据前完成 pending 结算;公开草稿已写入时不得重复推进 revision。 +- 权威失败结算与生成流程内部错误统一先将私有 ledger 写为 `failure-settlement-pending` 或 `reconciliation-settlement-pending`,再幂等更新公开草稿与 staging revision,最后发布 `failed` 或 `reconciliation-required`,任何路径都不得先发布终态 ledger。任一持久化边界中断后,启动恢复必须在解析生成服务凭据前完成 pending 结算;公开草稿或 staging revision 已写入时不得重复推进。 - 候选成功回包不得整体 hydrate 覆盖生成期间的本地未 autosave 状态;必须以当前 runtime 的图层、viewport、背景、selection 和 history 为基线,只合并权威候选图层、预览地址与 generation 记录,并用一次带 CAS 的草稿保存确认候选。 - 候选生成必须先幂等落盘候选媒体、候选图层和公开记录,回读成功后才能推进私有 ledger 的 `candidate-ready` 终态;两次写入之间中断时,恢复必须可重放。 - 失败归档使用 `archive-pending` 中间态:先记录归档意图,再删除草稿公开记录,最后发布 `archived`;任一边界中断都必须在重启后收敛且保持幂等。