Merge branch 'feat/agc-generation-task-progress' into fix/agc-canvas-acceptance-batch
This commit is contained in:
@@ -112,7 +112,9 @@ const allowedUncalledTauriCommands = [
|
||||
'check_ui_editor_font_glyph_coverage',
|
||||
'create_ui_design_resource',
|
||||
// 图片类生成的同步变体:GUI 已改为 `start_local_project_asset_generation` + 项目内任务账本
|
||||
// (提交即返回、后台生成),这条命令保留给 Rust 侧与既有单测,不再有前端调用方。
|
||||
// (提交即返回、后台生成)。这条命令**没有生产调用方**,只有 Rust 集成测试
|
||||
// (`src/tests/project.rs`)与 `commands.rs` 单测在调;待后续批次删除,或改为转调
|
||||
// `start_local_project_asset_generation`。
|
||||
'generate_local_project_asset',
|
||||
'open_game_creator_launcher_window',
|
||||
'open_game_creator_workspace_window',
|
||||
|
||||
@@ -14,10 +14,12 @@
|
||||
//!
|
||||
//! 账本落在**项目内** `.agent/runtime/asset-generation-tasks/tasks.json`(复用既有 agent runtime
|
||||
//! sidecar 读写原语:临时文件 + rename 替换),所以重开项目后仍能看到历史任务。进程重启时
|
||||
//! 还在 `queued` / `running` 的记录不可能再有人推进,读账本时按「上次运行中断」收口为失败,
|
||||
//! 不假装它还在跑。
|
||||
//! 还在 `queued` / `running` 的记录不可能再有人推进,读账本时按「上次运行中断」收口;收口结论
|
||||
//! **要跟 manifest 交叉核对**:能在清单里找到这次请求的目标素材就按已完成收口(生成通道是先写
|
||||
//! manifest 再返回的,所以「素材已登记、账本还停在 running」的窗口里崩溃是真会发生的),核不了
|
||||
//! 就不把话说死,不假装它还在跑、也不谎报「生成未完成」。
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
@@ -27,7 +29,7 @@ use crate::agent::{
|
||||
write_agent_runtime_json_sidecar_with_max_bytes, PlatformArtAssetGenerationOptions,
|
||||
};
|
||||
use crate::commands::prepare_local_project_asset_generation;
|
||||
use crate::project::enforce_project_permission_policy;
|
||||
use crate::project::{enforce_project_permission_policy, read_existing_manifest_for_project};
|
||||
|
||||
pub(crate) const ASSET_GENERATION_TASK_SCHEMA_VERSION: &str = "agc-asset-generation-task.v1";
|
||||
pub(crate) const ASSET_GENERATION_TASK_LEDGER_RELATIVE_PATH: &str =
|
||||
@@ -45,8 +47,21 @@ pub(crate) const ASSET_GENERATION_TASK_STATUS_FAILED: &str = "failed";
|
||||
const ASSET_GENERATION_TASK_PHASE_QUEUED: &str = "排队中。";
|
||||
const ASSET_GENERATION_TASK_PHASE_RUNNING: &str = "正在生成。";
|
||||
const ASSET_GENERATION_TASK_PHASE_COMPLETED: &str = "生成已完成。";
|
||||
const ASSET_GENERATION_TASK_PHASE_INTERRUPTED: &str = "上次运行中断,生成未完成。";
|
||||
const ASSET_GENERATION_TASK_INTERRUPTED_ERROR: &str = "应用退出时生成任务仍在进行";
|
||||
/// 中断收口:清单里已经能查到这次请求的目标素材 → 按事实收口为已完成。
|
||||
const ASSET_GENERATION_TASK_PHASE_INTERRUPTED_SETTLED: &str =
|
||||
"上次运行中断,但目标素材已登记(已完成)。";
|
||||
/// 中断收口:请求指定了精确落点,而清单里没有该素材 → 这次写入确实没落地。
|
||||
const ASSET_GENERATION_TASK_PHASE_INTERRUPTED_INCOMPLETE: &str =
|
||||
"上次运行中断,目标素材未登记,生成未完成。";
|
||||
/// 中断收口:没有可核对的目标标识(或清单读不到)→ 不下结论。
|
||||
const ASSET_GENERATION_TASK_PHASE_INTERRUPTED_UNKNOWN: &str =
|
||||
"上次运行中断,状态未知(可能已完成)。";
|
||||
const ASSET_GENERATION_TASK_INTERRUPTED_SETTLED_ERROR: &str =
|
||||
"应用退出时生成任务仍在进行,已按清单确认目标素材登记";
|
||||
const ASSET_GENERATION_TASK_INTERRUPTED_INCOMPLETE_ERROR: &str =
|
||||
"应用退出时生成任务仍在进行,目标素材未登记";
|
||||
const ASSET_GENERATION_TASK_INTERRUPTED_UNKNOWN_ERROR: &str =
|
||||
"应用退出时生成任务仍在进行,未能在清单里确认结果";
|
||||
|
||||
/// 一条生成任务的权威记录。字段名与前端一一对应(camelCase)。
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
@@ -59,6 +74,13 @@ pub(crate) struct AssetGenerationTaskRecord {
|
||||
pub(crate) status: String,
|
||||
/// 阶段文案:**由后端拥有**,前端只渲染。
|
||||
pub(crate) phase_detail: String,
|
||||
/// 这次请求指定的精确落点(`outputPath`)。
|
||||
///
|
||||
/// 它是中断收口时**唯一可核对的目标标识**:有它就能拿 manifest 的 `localPath` 做精确匹配,
|
||||
/// 判出「素材已登记 → 已完成」还是「落点没有素材 → 未落地」。没有它(绝大多数入口不指定
|
||||
/// 落点)就只能报「状态未知」。旧账本没有这个字段,按 `None` 读。
|
||||
#[serde(default)]
|
||||
pub(crate) output_path: Option<String>,
|
||||
pub(crate) created_at_millis: u64,
|
||||
pub(crate) started_at_millis: Option<u64>,
|
||||
pub(crate) finished_at_millis: Option<u64>,
|
||||
@@ -145,11 +167,45 @@ fn trim_ledger(tasks: &mut Vec<AssetGenerationTaskRecord>) {
|
||||
tasks.drain(0..overflow);
|
||||
}
|
||||
|
||||
/// 进程重启后把没人推进的记录收口为失败,并把这次修复写回账本。
|
||||
/// manifest 的 `localPath` → 资产 id。
|
||||
///
|
||||
/// 只用来做**精确落点**的交叉核对,所以按同一个口径归一化两侧的路径串(去空白、反斜杠折成
|
||||
/// 正斜杠);不做模糊匹配、不按素材名猜,避免把另一次生成的产物算到这条任务上。
|
||||
fn registered_asset_ids_by_local_path(root: &Path) -> Option<BTreeMap<String, String>> {
|
||||
let manifest = read_existing_manifest_for_project(root).ok()?;
|
||||
Some(
|
||||
manifest
|
||||
.assets
|
||||
.iter()
|
||||
.map(|asset| (normalize_local_path(&asset.local_path), asset.id.clone()))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize_local_path(path: &str) -> String {
|
||||
path.trim().replace('\\', "/")
|
||||
}
|
||||
|
||||
/// 中断收口用到的 manifest 交叉核对口径。
|
||||
///
|
||||
/// `None` = 这次读不到清单(项目还没初始化 / 读盘失败),此时任何记录都不能下结论。
|
||||
type RegisteredAssetIdsByLocalPath = Option<BTreeMap<String, String>>;
|
||||
|
||||
/// 进程重启后把没人推进的记录收口,并把这次修复写回账本。
|
||||
///
|
||||
/// 判据是「本进程的 live 集合里没有它」:本进程派发的任务在 `start` 里先登记 live 再落账本,
|
||||
/// 所以账本里非终态且不 live 的记录只可能来自上一次运行。
|
||||
fn repair_interrupted_tasks(tasks: &mut [AssetGenerationTaskRecord]) -> bool {
|
||||
///
|
||||
/// **收口结论要跟清单核对**:生成通道是先写 manifest 再返回的,所以「素材已经登记、账本还停在
|
||||
/// `running`」的窗口里崩溃是真会发生的;只看 live 集合会把这种任务谎报成「生成未完成」。三种结论:
|
||||
///
|
||||
/// - 请求指定了精确落点、且清单里已有该落点 → 按事实收口为**已完成**(带上 assetId);
|
||||
/// - 请求指定了精确落点、清单里没有 → 这次写入确实没落地,收口为失败并说明「目标素材未登记」;
|
||||
/// - 没有精确落点(或清单读不到)→ 收口为失败但**不下结论**,文案是「状态未知(可能已完成)」。
|
||||
fn repair_interrupted_tasks(
|
||||
tasks: &mut [AssetGenerationTaskRecord],
|
||||
registered: &RegisteredAssetIdsByLocalPath,
|
||||
) -> bool {
|
||||
let live = live_task_ids()
|
||||
.lock()
|
||||
.map(|ids| ids.clone())
|
||||
@@ -159,22 +215,44 @@ fn repair_interrupted_tasks(tasks: &mut [AssetGenerationTaskRecord]) -> bool {
|
||||
if task.is_terminal() || live.contains(&task.task_id) {
|
||||
continue;
|
||||
}
|
||||
task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string();
|
||||
task.phase_detail = ASSET_GENERATION_TASK_PHASE_INTERRUPTED.to_string();
|
||||
task.error = Some(ASSET_GENERATION_TASK_INTERRUPTED_ERROR.to_string());
|
||||
let registered_asset = task
|
||||
.output_path
|
||||
.as_deref()
|
||||
.map(normalize_local_path)
|
||||
.filter(|path| !path.is_empty())
|
||||
.and_then(|path| registered.as_ref().map(|assets| assets.get(&path).cloned()));
|
||||
match registered_asset {
|
||||
Some(Some(asset_id)) => {
|
||||
task.status = ASSET_GENERATION_TASK_STATUS_COMPLETED.to_string();
|
||||
task.phase_detail = ASSET_GENERATION_TASK_PHASE_INTERRUPTED_SETTLED.to_string();
|
||||
task.asset_id = Some(asset_id);
|
||||
task.error = Some(ASSET_GENERATION_TASK_INTERRUPTED_SETTLED_ERROR.to_string());
|
||||
}
|
||||
Some(None) => {
|
||||
task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string();
|
||||
task.phase_detail = ASSET_GENERATION_TASK_PHASE_INTERRUPTED_INCOMPLETE.to_string();
|
||||
task.error = Some(ASSET_GENERATION_TASK_INTERRUPTED_INCOMPLETE_ERROR.to_string());
|
||||
}
|
||||
None => {
|
||||
task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string();
|
||||
task.phase_detail = ASSET_GENERATION_TASK_PHASE_INTERRUPTED_UNKNOWN.to_string();
|
||||
task.error = Some(ASSET_GENERATION_TASK_INTERRUPTED_UNKNOWN_ERROR.to_string());
|
||||
}
|
||||
}
|
||||
task.finished_at_millis = Some(now_millis());
|
||||
repaired = true;
|
||||
}
|
||||
repaired
|
||||
}
|
||||
|
||||
/// 读账本:顺带把「上次运行中断」的任务收口为失败。
|
||||
/// 读账本:顺带把「上次运行中断」的任务收口(能核对的按事实收口,核不了的只报状态未知)。
|
||||
pub(crate) fn list_local_project_asset_generation_tasks(
|
||||
root: &Path,
|
||||
) -> Result<Vec<AssetGenerationTaskRecord>, String> {
|
||||
let _guard = lock_ledger()?;
|
||||
let mut tasks = read_ledger(root)?;
|
||||
if repair_interrupted_tasks(&mut tasks) {
|
||||
let registered = registered_asset_ids_by_local_path(root);
|
||||
if repair_interrupted_tasks(&mut tasks, ®istered) {
|
||||
write_ledger(root, &tasks)?;
|
||||
}
|
||||
Ok(tasks)
|
||||
@@ -221,6 +299,7 @@ pub(crate) fn begin_local_project_asset_generation_task(
|
||||
task_id: &str,
|
||||
task_kind: &str,
|
||||
asset_name: &str,
|
||||
output_path: Option<&str>,
|
||||
) -> Result<AssetGenerationTaskRecord, String> {
|
||||
let record = AssetGenerationTaskRecord {
|
||||
task_id: task_id.to_string(),
|
||||
@@ -229,6 +308,7 @@ pub(crate) fn begin_local_project_asset_generation_task(
|
||||
asset_name: asset_name.to_string(),
|
||||
status: ASSET_GENERATION_TASK_STATUS_QUEUED.to_string(),
|
||||
phase_detail: ASSET_GENERATION_TASK_PHASE_QUEUED.to_string(),
|
||||
output_path: output_path.map(str::to_string),
|
||||
created_at_millis: now_millis(),
|
||||
started_at_millis: None,
|
||||
finished_at_millis: None,
|
||||
@@ -331,6 +411,7 @@ pub(crate) async fn start_local_project_asset_generation(
|
||||
&task_id,
|
||||
&asset_kind,
|
||||
&asset_label,
|
||||
request.options.output_path.as_deref(),
|
||||
)?;
|
||||
// 先登记 live 再派发:`list` 只把「非终态且不 live」的记录判为上次运行的残留。
|
||||
if let Ok(mut ids) = live_task_ids().lock() {
|
||||
@@ -356,13 +437,22 @@ pub(crate) fn list_local_project_asset_generations(
|
||||
return Err("项目路径不能为空".to_string());
|
||||
}
|
||||
let root = Path::new(project_path);
|
||||
enforce_project_permission_policy(root, "asset.read")?;
|
||||
// 与相邻的 manifest 读命令同口径:合法的读 command id 是 `asset.list`(`asset.read` 不在
|
||||
// 契约的 command 列表里,写进 `denied_commands` 也不可能命中 → 门禁恒不生效)。
|
||||
enforce_project_permission_policy(root, "asset.list")?;
|
||||
list_local_project_asset_generation_tasks(root)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod asset_generation_task_tests {
|
||||
use super::*;
|
||||
use crate::assets::register_local_asset_at;
|
||||
use crate::project::{
|
||||
init_local_game_project_at, read_manifest_for_project, write_project_permission_policy_at,
|
||||
};
|
||||
use shared_contracts::game_creation_app::{
|
||||
GameCreationAppAssetSource, GameCreationAppAssetSourceKind,
|
||||
};
|
||||
|
||||
fn temp_project_root(label: &str) -> PathBuf {
|
||||
let nonce = SystemTime::now()
|
||||
@@ -377,9 +467,76 @@ mod asset_generation_task_tests {
|
||||
root
|
||||
}
|
||||
|
||||
/// 已初始化的项目:中断收口要读 manifest 做交叉核对,所以这几条用例必须有真清单。
|
||||
fn initialized_project_root(label: &str) -> PathBuf {
|
||||
let root = temp_project_root(label);
|
||||
init_local_game_project_at(&root, "project-1", "生成任务账本测试").expect("init project");
|
||||
root
|
||||
}
|
||||
|
||||
/// 登记一个素材到 manifest 并返回它的资产 id(中断收口按 `localPath` 精确匹配)。
|
||||
fn register_fixture_asset(root: &Path, local_path: &str) -> String {
|
||||
let absolute_path = root.join(local_path);
|
||||
if let Some(parent) = absolute_path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("create fixture asset parent");
|
||||
}
|
||||
std::fs::write(&absolute_path, b"png-bytes").expect("write fixture asset");
|
||||
register_local_asset_at(
|
||||
root,
|
||||
local_path,
|
||||
"icon-spec",
|
||||
"image/png",
|
||||
"generated",
|
||||
GameCreationAppAssetSource {
|
||||
kind: GameCreationAppAssetSourceKind::Generated,
|
||||
canvas_project_id: None,
|
||||
resource_id: None,
|
||||
asset_object_id: None,
|
||||
task_id: Some("fixture-generation".to_string()),
|
||||
prompt: None,
|
||||
model: None,
|
||||
generation_route: None,
|
||||
generation_kind: None,
|
||||
reference_resource_ids: Vec::new(),
|
||||
},
|
||||
)
|
||||
.expect("register fixture asset");
|
||||
read_manifest_for_project(root)
|
||||
.expect("read manifest after registration")
|
||||
.assets
|
||||
.iter()
|
||||
.find(|asset| asset.local_path == local_path)
|
||||
.expect("registered asset is present")
|
||||
.id
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn begin(root: &Path, task_id: &str) -> AssetGenerationTaskRecord {
|
||||
begin_local_project_asset_generation_task(root, "project-1", task_id, "image", "AI 图")
|
||||
.expect("begin task")
|
||||
begin_local_project_asset_generation_task(
|
||||
root,
|
||||
"project-1",
|
||||
task_id,
|
||||
"image",
|
||||
"AI 图",
|
||||
None,
|
||||
)
|
||||
.expect("begin task")
|
||||
}
|
||||
|
||||
fn begin_with_output(
|
||||
root: &Path,
|
||||
task_id: &str,
|
||||
output_path: &str,
|
||||
) -> AssetGenerationTaskRecord {
|
||||
begin_local_project_asset_generation_task(
|
||||
root,
|
||||
"project-1",
|
||||
task_id,
|
||||
"icon-spec",
|
||||
"图标规范",
|
||||
Some(output_path),
|
||||
)
|
||||
.expect("begin task with output path")
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -412,19 +569,20 @@ mod asset_generation_task_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restarting_the_process_settles_unfinished_tasks_as_interrupted() {
|
||||
let root = temp_project_root("interrupted");
|
||||
fn restarting_the_process_reports_unknown_state_when_the_result_cannot_be_checked() {
|
||||
// 项目没初始化 → 读不到 manifest → 没有可核对的目标标识:不许断言「生成未完成」。
|
||||
let root = temp_project_root("interrupted-unknown");
|
||||
begin(&root, "task-interrupted");
|
||||
// 模拟「上一条进程留下的非终态记录」:live 集合里没有它(本测试进程从未 start 过它)。
|
||||
let listed = list_local_project_asset_generation_tasks(&root).expect("list");
|
||||
assert_eq!(listed[0].status, ASSET_GENERATION_TASK_STATUS_FAILED);
|
||||
assert_eq!(
|
||||
listed[0].phase_detail,
|
||||
ASSET_GENERATION_TASK_PHASE_INTERRUPTED
|
||||
ASSET_GENERATION_TASK_PHASE_INTERRUPTED_UNKNOWN
|
||||
);
|
||||
assert_eq!(
|
||||
listed[0].error.as_deref(),
|
||||
Some(ASSET_GENERATION_TASK_INTERRUPTED_ERROR)
|
||||
Some(ASSET_GENERATION_TASK_INTERRUPTED_UNKNOWN_ERROR)
|
||||
);
|
||||
assert!(listed[0].finished_at_millis.is_some());
|
||||
// 修复要写回账本,第二次读到的仍是同一条终态记录。
|
||||
@@ -433,6 +591,76 @@ mod asset_generation_task_tests {
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interrupted_task_whose_output_is_already_registered_settles_as_completed() {
|
||||
// 生成通道先写 manifest 再返回,所以「素材已登记、账本还停在 running」的窗口里崩溃是
|
||||
// 真会发生的:这种任务必须按事实收口为已完成,而不是谎报「生成未完成」。
|
||||
let root = initialized_project_root("interrupted-settled");
|
||||
let asset_id = register_fixture_asset(&root, "assets/art-spec.png");
|
||||
begin_with_output(&root, "task-settled", "assets/art-spec.png");
|
||||
|
||||
let listed = list_local_project_asset_generation_tasks(&root).expect("list");
|
||||
assert_eq!(listed[0].status, ASSET_GENERATION_TASK_STATUS_COMPLETED);
|
||||
assert_eq!(
|
||||
listed[0].phase_detail,
|
||||
ASSET_GENERATION_TASK_PHASE_INTERRUPTED_SETTLED
|
||||
);
|
||||
assert_eq!(listed[0].asset_id.as_deref(), Some(asset_id.as_str()));
|
||||
assert!(listed[0].finished_at_millis.is_some());
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interrupted_task_whose_output_was_never_registered_is_reported_as_incomplete() {
|
||||
let root = initialized_project_root("interrupted-incomplete");
|
||||
begin_with_output(&root, "task-incomplete", "assets/never-written.png");
|
||||
|
||||
let listed = list_local_project_asset_generation_tasks(&root).expect("list");
|
||||
assert_eq!(listed[0].status, ASSET_GENERATION_TASK_STATUS_FAILED);
|
||||
assert_eq!(
|
||||
listed[0].phase_detail,
|
||||
ASSET_GENERATION_TASK_PHASE_INTERRUPTED_INCOMPLETE
|
||||
);
|
||||
assert_eq!(
|
||||
listed[0].error.as_deref(),
|
||||
Some(ASSET_GENERATION_TASK_INTERRUPTED_INCOMPLETE_ERROR)
|
||||
);
|
||||
assert!(listed[0].asset_id.is_none());
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interrupted_task_without_an_output_slot_never_claims_the_generation_failed() {
|
||||
// 自动落点(绝大多数入口):清单里无法精确定位这次请求的产物 → 只报状态未知。
|
||||
let root = initialized_project_root("interrupted-no-output");
|
||||
register_fixture_asset(&root, "assets/art-spec.png");
|
||||
begin(&root, "task-no-output");
|
||||
|
||||
let listed = list_local_project_asset_generation_tasks(&root).expect("list");
|
||||
assert_eq!(listed[0].status, ASSET_GENERATION_TASK_STATUS_FAILED);
|
||||
assert_eq!(
|
||||
listed[0].phase_detail,
|
||||
ASSET_GENERATION_TASK_PHASE_INTERRUPTED_UNKNOWN
|
||||
);
|
||||
assert!(listed[0].asset_id.is_none());
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_policy_can_deny_the_generation_task_ledger_read() {
|
||||
// 读门禁必须用契约里真实存在的 command id(`asset.list`)。写成 `asset.read` 这种不在
|
||||
// 命令表里的名字时,`denied_commands` 永远不可能命中 → 门禁恒不生效。
|
||||
let root = initialized_project_root("denied-read");
|
||||
let mut policy = crate::ProjectPermissionPolicy::default();
|
||||
policy.denied_commands.push("asset.list".to_string());
|
||||
write_project_permission_policy_at(&root, policy).expect("write permission policy");
|
||||
|
||||
let error = list_local_project_asset_generations(root.to_string_lossy().into_owned())
|
||||
.expect_err("denied ledger read must fail closed");
|
||||
assert_eq!(error, "项目权限策略拒绝执行:asset.list");
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_tasks_are_not_settled_while_they_are_still_running() {
|
||||
let root = temp_project_root("live");
|
||||
@@ -446,6 +674,10 @@ mod asset_generation_task_tests {
|
||||
remove_live_task_id("task-live");
|
||||
let settled = list_local_project_asset_generation_tasks(&root).expect("list settled");
|
||||
assert_eq!(settled[0].status, ASSET_GENERATION_TASK_STATUS_FAILED);
|
||||
assert_eq!(
|
||||
settled[0].phase_detail,
|
||||
ASSET_GENERATION_TASK_PHASE_INTERRUPTED_UNKNOWN
|
||||
);
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
@@ -477,6 +709,7 @@ mod asset_generation_task_tests {
|
||||
"task-dup",
|
||||
"image",
|
||||
"AI 图",
|
||||
None,
|
||||
)
|
||||
.expect_err("duplicate in-flight task");
|
||||
assert_eq!(error, "生成任务 id 已在进行中:task-dup");
|
||||
|
||||
+6
-4
@@ -66,8 +66,10 @@ export type ResourceCanvasAssetGenerationQueue = {
|
||||
* 入队并推进。
|
||||
*
|
||||
* resolve = 该任务已终态(成功);reject = 失败(含后端拒绝入参)。面板据此保留草稿可重试。
|
||||
* 排在别的在途任务后面的提交会先在本地队列里等,**不发 IPC**(远端图片类生成共用
|
||||
* single-flight 输出槽,并发提交会在远端 POST 之前被拒)。
|
||||
* 排在别的在途任务后面的提交会先在本地队列里等,**不发 IPC**——这是本批的**提交节流**:
|
||||
* AGC 本地 durable 输出槽已按精确动作指纹分槽,不同 prompt / 素材名可以同时在途,所以排队
|
||||
* 不再是「远端会拒绝并发」的被迫行为;真并行派发需要并发收口设计(配对读 + manifest CAS +
|
||||
* 聚焦意图互不覆盖),留待下一批。
|
||||
*/
|
||||
submit(task: ResourceCanvasAssetGenerationTask): Promise<void>;
|
||||
/** 推进已有队列(宿主挂载 / 重开项目恢复任务时调一次)。 */
|
||||
@@ -92,8 +94,8 @@ type SettlementWaiter = {
|
||||
/**
|
||||
* 本地排队 + 后端任务账本的驱动器。
|
||||
*
|
||||
* 顺序上只有一条规则:**下一条必须等上一条终态**;上一条结束(成功或失败)后由同一个循环
|
||||
* 自动补发。派发之后不再由前端猜进度:状态与阶段文案一律来自
|
||||
* 顺序上只有一条规则:**下一条必须等上一条终态**(本批的提交节流,见 `submit`);上一条结束
|
||||
* (成功或失败)后由同一个循环自动补发。派发之后不再由前端猜进度:状态与阶段文案一律来自
|
||||
* `list_local_project_asset_generations` 返回的后端记录。
|
||||
*/
|
||||
export function createResourceCanvasAssetGenerationQueue(
|
||||
|
||||
+5
-4
@@ -253,13 +253,14 @@ export function mergeLocalProjectAssetGenerationRecord(
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地排队的派发判据:**同一时刻只允许一条任务在途**。
|
||||
* 本地排队的派发判据:**同一时刻只派发一条任务**(本批的提交节流)。
|
||||
*
|
||||
* 远端图片类生成共用 single-flight 输出槽,第二条并发请求会在远端 POST 之前被拒;所以第二条
|
||||
* 必须停在本地队列里(`dispatched === false`),等前一条终态后再补发。
|
||||
* AGC 本地 durable 输出槽已按**精确动作指纹**分槽,不同 prompt / 素材名可以同时在途,所以这条
|
||||
* 判据**不再是「远端会拒绝并发」的被迫排队**;本批前端仍按「同一时刻只派发一条」排队,作为提交
|
||||
* 节流。真并行派发需要并发收口设计(配对读 + manifest CAS + 聚焦意图互不覆盖),留待下一批。
|
||||
*
|
||||
* 「在途」的判据是 `dispatched && 未终态`,而不是 `status === 'running'`:已派发但后端记录还
|
||||
* 没读回来的那一段(状态仍是 `queued`)同样占着输出槽,漏掉这一档就会并发发出第二条。
|
||||
* 没读回来的那一段(状态仍是 `queued`)同样算在途,漏掉这一档就会提前放出第二条。
|
||||
*/
|
||||
export function nextResourceCanvasAssetGenerationDispatch(
|
||||
tasks: readonly ResourceCanvasAssetGenerationTask[],
|
||||
|
||||
+2
-2
@@ -21,7 +21,7 @@ import type { ResourceCanvasGenerationKind } from './resourceCanvasGenerationMod
|
||||
*/
|
||||
|
||||
/**
|
||||
* `generate_local_project_asset` 放行的无源生成类型。
|
||||
* `start_local_project_asset_generation` 放行的无源生成类型。
|
||||
*
|
||||
* 与 Rust `PLATFORM_ART_ASSET_GENERATION_KINDS` 一一对应(`publication-material` 属宣发
|
||||
* 素材,本轮不做,因此不在这里)。`spec` / `icon-spec` 都会在 Rust 侧收口到 `icon-spec`,
|
||||
@@ -72,7 +72,7 @@ type ResourceCanvasBottomToolActionBase = {
|
||||
imageSize: string;
|
||||
};
|
||||
|
||||
/** 图片类生成入口:走 `generate_local_project_asset`。 */
|
||||
/** 图片类生成入口:走 `start_local_project_asset_generation`(提交即返回、后台生成)。 */
|
||||
export type ResourceCanvasAssetToolAction =
|
||||
ResourceCanvasBottomToolActionBase & {
|
||||
route: 'asset';
|
||||
|
||||
Reference in New Issue
Block a user