生成任务先登记 live 再落账本,关掉被判成中断残留的窗口

- `start_local_project_asset_generation` 的音频分支改为一律先登记 live、再写排队记录(原先顺序
  相反,中间窗口里并发 `list_local_project_asset_generations` 会把这条排队记录当成上次运行的
  残留收口成失败)
- 图片类分支同一处顺序问题一并改正:同一条命令的两条通道共用同一条顺序约束
- 新增 `register_live_task_id`:登记并返回「本轮是否真的插入了新 id」,落账失败时只回滚本轮插入
  的那条登记,避免删掉同 id 那个正在运行任务的 live 登记
- 新增用例 `a_failed_ledger_write_only_takes_back_the_live_registration_it_inserted` 覆盖该回滚判据
This commit is contained in:
2026-09-21 00:14:11 +08:00
parent ae0ab0ddf4
commit 7b1bc1d3e8
@@ -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");