diff --git a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs index 5a4f372f7..0fd3f96d6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs @@ -342,6 +342,21 @@ fn remove_live_task_id(task_id: &str) { } } +/// 登记一条「本进程正在推的任务」,返回 true 表示这次确实插入了新的 id。 +/// +/// 顺序是硬约束:**先登记 live 再落账本**。`list` 只把「非终态且不 live」的记录判为上次运行的 +/// 残留,反过来先落账本就会留出一个窗口——并发 `list` 会在窗口里把刚排队的任务收口成失败, +/// 前端随即看到一条本不存在的失败记录。 +/// +/// 返回值专给「落账失败要回滚」用:只有真插入过的一方才有资格回滚,否则会把**同 id 那个正在 +/// 运行的任务**的 live 登记一起删掉(随后 `list` 就会把它谎报成上次运行的中断残留)。 +fn register_live_task_id(task_id: &str) -> bool { + live_task_ids() + .lock() + .map(|mut ids| ids.insert(task_id.to_string())) + .unwrap_or(false) +} + /// 后台执行:状态与阶段文案的每一次流转都由这里写账本。 async fn run_local_project_asset_generation_task( root: PathBuf, @@ -531,7 +546,9 @@ pub(crate) async fn start_local_project_asset_generation( if idempotency_key.trim().is_empty() { return Err("音频生成缺少 idempotencyKey".to_string()); } - let (record, request) = begin_local_project_audio_generation_task( + // 先登记 live 再落账本:窗口期里并发 `list` 不许把这条排队记录判成上次运行的残留。 + let live_registered = register_live_task_id(&task_id); + let (record, request) = match begin_local_project_audio_generation_task( &project_path, &project_id, &task_id, @@ -539,11 +556,16 @@ pub(crate) async fn start_local_project_asset_generation( &prompt, asset_name.as_deref().unwrap_or_default(), &idempotency_key, - )?; - // 先登记 live 再派发:`list` 只把「非终态且不 live」的记录判为上次运行的残留。 - if let Ok(mut ids) = live_task_ids().lock() { - ids.insert(task_id.clone()); - } + ) { + Ok(pair) => pair, + Err(error) => { + // 校验不过 / 同 id 已在跑 / 账本写不进去:这一轮什么都没派发,撤掉自己的登记。 + if live_registered { + remove_live_task_id(&task_id); + } + return Err(error); + } + }; tauri::async_runtime::spawn(run_local_project_audio_generation_task( project_path.trim().to_string(), task_id, @@ -566,18 +588,24 @@ pub(crate) async fn start_local_project_asset_generation( enforce_project_permission_policy(&request.root, "asset.register")?; let asset_label = request.options.asset_label.clone(); let asset_kind = request.options.asset_kind.clone(); - let record = begin_local_project_asset_generation_task( + // 与音频分支同一条顺序约束:先登记 live 再落账本,中间不留「排队但还不 live」的窗口。 + let live_registered = register_live_task_id(&task_id); + let record = match begin_local_project_asset_generation_task( &request.root, &project_id, &task_id, asset_kind, &asset_label, request.options.output_path.as_deref(), - )?; - // 先登记 live 再派发:`list` 只把「非终态且不 live」的记录判为上次运行的残留。 - if let Ok(mut ids) = live_task_ids().lock() { - ids.insert(task_id.clone()); - } + ) { + Ok(record) => record, + Err(error) => { + if live_registered { + remove_live_task_id(&task_id); + } + return Err(error); + } + }; let root = request.root.clone(); tauri::async_runtime::spawn(run_local_project_asset_generation_task( root, @@ -842,6 +870,49 @@ mod asset_generation_task_tests { std::fs::remove_dir_all(&root).ok(); } + /// 落账失败要回滚的是「**本轮**插入的那条登记」,不是「这个 id」。 + /// + /// 同 id 已经在跑时,`start` 的第二轮不会插入新登记;这时如果按 id 回滚,就会把正在跑的 + /// 那条任务的 live 登记一起删掉,`list` 随后把它谎报成上次运行的中断残留。 + #[test] + fn a_failed_ledger_write_only_takes_back_the_live_registration_it_inserted() { + let root = temp_project_root("live-rollback"); + // 第一次提交:先登记 live,再落账(真实链路里紧接着 spawn)。 + assert!( + register_live_task_id("task-in-flight"), + "首次登记必须报告为「本轮插入」" + ); + begin(&root, "task-in-flight"); + + // 第二次提交(同 id):这一轮没有插入新登记,落账也会因「已在进行中」被拒。 + let live_registered = register_live_task_id("task-in-flight"); + assert!( + !live_registered, + "同 id 已在 live 集合里时,本轮不得报告为「本轮插入」" + ); + let error = begin_local_project_asset_generation_task( + &root, + "project-1", + "task-in-flight", + GameCreationAppAssetKind::Image, + "AI 图", + None, + ) + .expect_err("duplicate in-flight task"); + assert_eq!(error, "生成任务 id 已在进行中:task-in-flight"); + if live_registered { + remove_live_task_id("task-in-flight"); + } + + // 正在跑的任务仍是 live:`list` 不得把它收口成失败。 + let listed = list_local_project_asset_generation_tasks(&root).expect("list"); + assert_eq!(listed[0].status, ASSET_GENERATION_TASK_STATUS_QUEUED); + assert_eq!(listed[0].phase_detail, ASSET_GENERATION_TASK_PHASE_QUEUED); + + remove_live_task_id("task-in-flight"); + std::fs::remove_dir_all(&root).ok(); + } + #[test] fn completing_a_task_records_the_manifest_asset_id_and_keeps_it() { let root = temp_project_root("completed");