修复素材导入与资源提交的项目锁争用 (#581)
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled

修复 #551:本地素材导入、账户素材提交和资源编辑提交在 conversation.write 短暂持有项目锁时会立即失败。现在复用既有约 10 秒有界等待,并将同步取锁与提交放入阻塞线程池,等待期间异步 runtime 继续响应。

- 项目锁先于账号身份租约,等待后重新验证账号身份、导入权限及原 WritePermit;保留跨进程互斥、权限分类和持锁方诊断。
- 资源资产/版本提交和已完成结果清理保留 operation/project guard,取消异步等待不会提前释放仍在执行的提交互斥。超时保留原 operation、幂等键与 staging,恢复不重复远端生成。
- Runtime 素材导入与 UI workflow 切图登记也接入阻塞池,避免共享导入函数新增等待后阻塞这些调用方。同步更新 AGC 行为规范及共享记忆。

验证:163 项 Rust 定向测试通过,覆盖导入与资源编辑、Direct bridge、Runtime 导入、UI workflow、项目锁恢复和失败分类。包含同进程争用、单线程 runtime 心跳、并行 manifest/revision、账号切换、许可取消、完整超时后原 operation 恢复及取消后的 guard 生命周期。

```sh
cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell -- resource_editor:: commands::agent_asset_import_tests agent::direct_tool_bridge::tests tests::project_lock_recovery project::write_lock agent::runtime_tools::context::asset_import_input_tests ui_editor::agent_tools --test-threads=1
npm run check:rustfmt
npm run check:encoding
npm run check:doc-index
git diff --check
```

全部上述检查通过。Linux 本地未执行 Windows 专属删除挂起行为测试;未调用真实付费 Provider,远端链路使用既有 mock 测试和已下载账本恢复测试验证。没有 API/DTO/schema 变更或数据迁移。

Closes #551

Reviewed-on: #581
Co-authored-by: lhk <ink29535@proton.me>
Co-committed-by: lhk <ink29535@proton.me>
This commit was merged in pull request #581.
This commit is contained in:
2026-10-02 10:55:29 +08:00
committed by 孔令弘
parent 92467dd53e
commit 89d40c0836
9 changed files with 605 additions and 64 deletions
@@ -1622,11 +1622,13 @@ async fn bridge_import_account_assets(
}
}
if !local_paths.is_empty() {
match crate::commands::import_local_project_assets_for_agent_with_write_permit(
match crate::commands::import_local_project_assets_for_agent_in_blocking_pool(
&state.root,
&local_paths,
write_permit.as_ref(),
) {
)
.await
{
Ok(result) => imported.extend(result.assets.into_iter().map(|asset| {
json!({
"id": asset.id,
@@ -4748,21 +4750,39 @@ mod tests {
let revision_before = read_game_creator_agent_runtime_project_revision(root)
.unwrap()
.revision;
session
.interrupt("fixture cancellation before asset commit".into())
.unwrap();
assert!(
crate::commands::import_local_project_assets_for_agent_with_write_permit(
assert!(crate::tests::with_conversation_write_contention(
root,
crate::commands::import_local_project_assets_for_agent_in_blocking_pool(
root,
&["game/import-source.png".to_string()],
Some(&permit),
)
.is_err()
);
assert!(
crate::commands::commit_account_asset_for_write_permit_test(root, &bytes, &permit)
.is_err()
);
),
|| session
.interrupt("fixture cancellation during asset lock wait".into())
.unwrap(),
)
.await
.is_err());
let commit_root = root.to_path_buf();
let commit_bytes = bytes.clone();
let commit_permit = permit.clone();
assert!(crate::tests::with_conversation_write_contention(
root,
async move {
tokio::task::spawn_blocking(move || {
crate::commands::commit_account_asset_for_write_permit_test(
&commit_root,
&commit_bytes,
&commit_permit,
)
})
.await
.unwrap()
},
|| {}
)
.await
.is_err());
assert_eq!(
fs::read(root.join(".agent/manifest.json")).unwrap(),
manifest_before
@@ -583,7 +583,8 @@ pub(in crate::agent) async fn observe_agent_runtime_asset_import(
}
}
if !local_paths.is_empty() {
match import_local_project_assets_for_agent(root, &local_paths) {
match import_local_project_assets_for_agent_in_blocking_pool(root, &local_paths, None).await
{
Ok(result) => imported.extend(result.assets.into_iter().map(|asset| {
serde_json::json!({
"id": asset.id,
@@ -709,6 +710,35 @@ pub(in crate::agent) fn observe_agent_runtime_project_search(
mod asset_import_input_tests {
use super::*;
#[tokio::test]
async fn runtime_local_import_waits_without_blocking_worker() {
let project = tempfile::tempdir().unwrap();
let root = project.path();
init_local_game_project_at(root, "runtime-import-wait", "Runtime 导入等待").unwrap();
fs::write(root.join("game/import.md"), b"# Imported").unwrap();
let observation = crate::tests::with_conversation_write_contention(
root,
async {
let observation = observe_agent_runtime_asset_import(
root,
&serde_json::json!({"localPaths": ["game/import.md"]}),
)
.await;
if observation.status != "ok" {
return Err(format!("{observation:?}"));
}
Ok(observation)
},
|| {},
)
.await
.unwrap();
let detail: serde_json::Value =
serde_json::from_str(observation.detail.as_deref().unwrap()).unwrap();
assert_eq!(detail["imported"].as_array().unwrap().len(), 1);
assert_eq!(detail["revisionAdvanceCount"], 1);
}
#[test]
fn asset_list_observation_keeps_candidates_beyond_default_tool_limit() {
let temporary = tempfile::tempdir().expect("create asset context project root");
@@ -1981,6 +1981,126 @@ mod agent_asset_import_tests {
);
}
fn account_import_contention_downloads(
) -> Vec<(AgentEditorAssetRecord, String, String, Vec<u8>)> {
vec![(
AgentEditorAssetRecord {
asset_id: "account-contention".into(),
origin: AgentEditorAssetOrigin::AccountLibrary,
canvas_project_id: None,
folder_id: None,
folder_label: None,
label: "账户争用测试".into(),
object_key: None,
image_src: None,
asset_object_id: None,
asset_kind: Some("image".into()),
source_type: None,
width: None,
height: None,
size_bytes: None,
},
"image/png".into(),
"assets/uploads/account-contention.png".into(),
tiny_png(),
)]
}
#[tokio::test]
async fn asset_imports_wait_for_conversation_without_blocking_or_losing_updates() {
let project = crate::tests::canonical_test_tempdir("import-contention-");
let root = project.path();
init_local_game_project_at(root, "import-contention", "导入争用").unwrap();
fs::write(root.join("game/local.png"), tiny_png()).unwrap();
let before = read_game_creator_agent_runtime_project_revision(root)
.unwrap()
.revision;
let local_paths = vec!["game/local.png".to_string()];
let (local, account) = crate::tests::with_conversation_write_contention(
root,
async {
tokio::try_join!(
import_local_project_assets_for_agent_in_blocking_pool(
root,
&local_paths,
None,
),
commit_account_editor_asset_downloads_in_blocking_pool(
root,
account_import_contention_downloads(),
None,
None,
),
)
},
|| {},
)
.await
.unwrap();
let manifest = read_existing_manifest_for_project(root).unwrap();
for result in [&local, &account] {
assert_eq!(result.assets.len(), 1);
let asset = &result.assets[0];
assert!(manifest.assets.iter().any(|entry| entry.id == asset.id));
assert_eq!(fs::read(root.join(&asset.local_path)).unwrap(), tiny_png());
}
assert_eq!(
read_game_creator_agent_runtime_project_revision(root)
.unwrap()
.revision,
before + 2
);
}
#[tokio::test]
async fn account_asset_commit_rechecks_session_after_conversation_wait() {
let _session_guard = install_test_platform_session(
"import-owner-a",
"import-token-a",
"https://import.example.test",
);
let session = current_platform_session().unwrap();
let project = crate::tests::canonical_test_tempdir("import-session-contention-");
let root = project.path();
init_local_game_project_at(root, "import-session-contention", "导入身份争用").unwrap();
let before = fs::read(root.join(".agent/manifest.json")).unwrap();
let before_revision = read_game_creator_agent_runtime_project_revision(root)
.unwrap()
.revision;
let generation = session.identity_generation + 1;
let error = crate::tests::with_conversation_write_contention(
root,
commit_account_editor_asset_downloads_in_blocking_pool(
root,
account_import_contention_downloads(),
None,
Some(session),
),
|| {
install_platform_session(
"import-owner-b",
"import-token-b",
"https://import.example.test",
generation,
generation,
)
.unwrap();
},
)
.await
.err()
.expect("旧账号不能在等待后提交");
assert!(error.contains("authentication-required"), "{error}");
assert_eq!(fs::read(root.join(".agent/manifest.json")).unwrap(), before);
assert_eq!(
read_game_creator_agent_runtime_project_revision(root)
.unwrap()
.revision,
before_revision
);
assert!(!root.join("assets/uploads/account-contention.png").exists());
}
#[test]
fn local_project_asset_import_rejects_absolute_and_case_insensitive_agent_paths() {
let project = crate::tests::canonical_test_tempdir("agent-local-import-");
@@ -3237,6 +3357,25 @@ pub(crate) fn import_local_project_assets_for_agent(
import_local_project_assets_for_agent_with_write_permit(root, relative_paths, None)
}
pub(crate) async fn import_local_project_assets_for_agent_in_blocking_pool(
root: &Path,
relative_paths: &[String],
write_permit: Option<&crate::agent::WritePermit>,
) -> Result<RemoteImportResult, String> {
let root = root.to_path_buf();
let relative_paths = relative_paths.to_vec();
let write_permit = write_permit.cloned();
tokio::task::spawn_blocking(move || {
import_local_project_assets_for_agent_with_write_permit(
&root,
&relative_paths,
write_permit.as_ref(),
)
})
.await
.map_err(|error| format!("本地素材导入任务未返回:{error}"))?
}
pub(crate) fn import_local_project_assets_for_agent_with_write_permit(
root: &Path,
relative_paths: &[String],
@@ -3301,7 +3440,11 @@ pub(crate) fn import_local_project_assets_for_agent_with_write_permit(
));
}
let _lock = acquire_project_write_lock(root, "canvas.asset_import")?;
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
"canvas.asset_import",
)?;
enforce_project_permission_policy(root, "canvas.asset_import")?;
let commit = || {
let manifest = read_existing_manifest_for_project(root)?;
let mut imported = Vec::with_capacity(inputs.len());
@@ -3497,20 +3640,50 @@ pub(crate) async fn import_account_editor_assets_for_agent_with_write_permit(
}
access.validate_frozen_session()?;
let _platform_session_lease = frozen_session
.as_ref()
.map(|session| acquire_platform_session_identity_lease(&session.identity()))
.transpose()?;
// 身份租约覆盖本地提交;持租约期间不能再次读取登录态互斥锁。
commit_account_editor_asset_downloads(root, downloads, write_permit)
commit_account_editor_asset_downloads_in_blocking_pool(
root,
downloads,
write_permit,
frozen_session,
)
.await
}
async fn commit_account_editor_asset_downloads_in_blocking_pool(
root: &Path,
downloads: Vec<(AgentEditorAssetRecord, String, String, Vec<u8>)>,
write_permit: Option<&crate::agent::WritePermit>,
frozen_session: Option<PlatformSessionSnapshot>,
) -> Result<RemoteImportResult, String> {
let root = root.to_path_buf();
let write_permit = write_permit.cloned();
tokio::task::spawn_blocking(move || {
commit_account_editor_asset_downloads(
&root,
downloads,
write_permit.as_ref(),
frozen_session.as_ref(),
)
})
.await
.map_err(|error| format!("账户素材提交任务未返回:{error}"))?
}
fn commit_account_editor_asset_downloads(
root: &Path,
downloads: Vec<(AgentEditorAssetRecord, String, String, Vec<u8>)>,
write_permit: Option<&crate::agent::WritePermit>,
frozen_session: Option<&PlatformSessionSnapshot>,
) -> Result<RemoteImportResult, String> {
let _lock = acquire_project_write_lock(root, "canvas.asset_import")?;
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
"canvas.asset_import",
)?;
enforce_project_permission_policy(root, "canvas.asset_import")?;
// 等待后才取得身份租约;等待期间允许换号,提交时拒绝旧账号结果。
let _platform_session_lease = frozen_session
.map(|session| acquire_platform_session_identity_lease(&session.identity()))
.transpose()?;
let commit = || {
let manifest = read_existing_manifest_for_project(root)?;
let mut imported = Vec::with_capacity(downloads.len());
@@ -3639,6 +3812,7 @@ pub(crate) fn commit_account_asset_for_write_permit_test(
bytes.to_vec(),
)],
Some(write_permit),
None,
)
}
@@ -4302,7 +4302,8 @@ fn commit_resource_edit_asset_internal(
reference_resource_ids: vec![source.canonical_resource_id.clone()],
},
};
let _project_lock = acquire_project_write_lock(root, "resource.edit")?;
let _project_lock =
acquire_game_creator_agent_runtime_project_write_lock_with_wait(root, "resource.edit")?;
let mut manifest = read_existing_manifest_for_project(root)?;
if manifest.project_id != input.expected_project_id {
return Err("project-identity-conflict".to_string());
@@ -4615,6 +4616,10 @@ fn commit_resource_edit_asset_with_frozen_platform_session(
ledger: &mut ResourceEditLedger,
platform_session: Option<&PlatformSessionSnapshot>,
) -> Result<DeriveLocalProjectResourceResult, String> {
// 项目锁先于平台身份租约,避免等待时挡住换号或与其它提交形成锁顺序反转。
// 内层提交在同一线程复用项目锁,整个事务不能跨 await 或线程迁移。
let _project_lock =
acquire_game_creator_agent_runtime_project_write_lock_with_wait(root, "resource.edit")?;
with_frozen_resource_edit_platform_session(platform_session, || {
commit_resource_edit_asset(root, input, source, prompt, asset_name, ledger)
})
@@ -4696,7 +4701,10 @@ fn commit_resource_edit_version(
ledger: &mut ResourceEditLedger,
) -> Result<DeriveLocalProjectResourceResult, String> {
let version_id = format!("edit-{}", input.operation_id);
let _project_lock = acquire_project_write_lock(root, "resource.edit.version")?;
let _project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
"resource.edit.version",
)?;
let mut manifest = read_existing_manifest_for_project(root)?;
if manifest.project_id != input.expected_project_id {
return Err("project-identity-conflict".to_string());
@@ -5518,7 +5526,7 @@ pub(crate) async fn derive_local_project_resource_at(
let root = Path::new(input.project_path.trim());
validate_project_root(root)?;
let operation_lock = resource_edit_operation_lock(root, &input.operation_id)?;
let _operation_guard = operation_lock.lock().await;
let operation_guard = operation_lock.lock_owned().await;
let manifest = read_existing_manifest_for_project(root)?;
if manifest.project_id != input.expected_project_id {
return Err("project-identity-conflict".to_string());
@@ -5652,23 +5660,36 @@ pub(crate) async fn derive_local_project_resource_at(
ledger
}
};
if ledger.phase == ResourceEditLedgerPhase::Committed {
if ledger.phase == ResourceEditLedgerPhase::Committed
|| input.edit_kind == LocalProjectResourceEditKind::Version
{
let project_lock = resource_edit_project_mutation_lock(root)?;
let _project_guard = project_lock.lock().await;
let _project_write_lock = acquire_project_write_lock(root, "resource.edit.cleanup")?;
cleanup_committed_resource_edit_staging(root, &mut ledger)?;
return committed_resource_edit_result(
root,
&input,
&source.canonical_resource_id,
ledger.result_asset_id.as_deref(),
ledger.result_version_id.as_deref(),
);
}
if input.edit_kind == LocalProjectResourceEditKind::Version {
let project_lock = resource_edit_project_mutation_lock(root)?;
let _project_guard = project_lock.lock().await;
return commit_resource_edit_version(root, &input, &source, &prompt, &mut ledger);
let project_guard = project_lock.lock_owned().await;
let root = root.to_path_buf();
return tokio::task::spawn_blocking(move || {
// 调用方取消等待时,互斥仍由正在运行的提交持有。
let _operation_guard = operation_guard;
let _project_guard = project_guard;
if ledger.phase == ResourceEditLedgerPhase::Committed {
let _project_write_lock =
acquire_game_creator_agent_runtime_project_write_lock_with_wait(
&root,
"resource.edit.cleanup",
)?;
cleanup_committed_resource_edit_staging(&root, &mut ledger)?;
committed_resource_edit_result(
&root,
&input,
&source.canonical_resource_id,
ledger.result_asset_id.as_deref(),
ledger.result_version_id.as_deref(),
)
} else {
commit_resource_edit_version(&root, &input, &source, &prompt, &mut ledger)
}
})
.await
.map_err(|error| format!("资源编辑提交任务未返回:{error}"))?;
}
let remote_credentials = if input.edit_kind.is_remote_media()
&& ledger.phase != ResourceEditLedgerPhase::MediaDownloaded
@@ -5775,16 +5796,28 @@ pub(crate) async fn derive_local_project_resource_at(
}
}
let project_lock = resource_edit_project_mutation_lock(root)?;
let _project_guard = project_lock.lock().await;
let result = commit_resource_edit_asset_with_frozen_platform_session(
root,
&input,
&source,
&prompt,
&asset_name,
&mut ledger,
frozen_platform_session.as_ref(),
)?;
let project_guard = project_lock.lock_owned().await;
let commit_root = root.to_path_buf();
let commit_input = input.clone();
let commit_session = frozen_platform_session.clone();
let (result, ledger, _operation_guard, _project_guard) =
tokio::task::spawn_blocking(move || {
let operation_guard = operation_guard;
let project_guard = project_guard;
let result = commit_resource_edit_asset_with_frozen_platform_session(
&commit_root,
&commit_input,
&source,
&prompt,
&asset_name,
&mut ledger,
commit_session.as_ref(),
)?;
// 正常返回仍覆盖后续 binding 写入;取消等待则在阻塞任务结束后释放。
Ok::<_, String>((result, ledger, operation_guard, project_guard))
})
.await
.map_err(|error| format!("资源编辑提交任务未返回:{error}"))??;
if let (Some((api_base_url, api_key, _)), Some(principal)) =
(remote_credentials.as_ref(), binding_principal.as_ref())
{
@@ -6462,7 +6495,7 @@ mod tests {
drop(other_project_guard);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[tokio::test]
async fn project_mutation_lock_serializes_independent_asset_commits_without_lost_updates() {
let directory = tempfile::tempdir().expect("create concurrent commit fixture");
let root = directory.path();
@@ -6513,14 +6546,31 @@ mod tests {
requests.push(request);
}
let (first, second) = tokio::join!(
derive_local_project_resource_at(requests[0].clone()),
derive_local_project_resource_at(requests[1].clone())
);
first.expect("commit first operation");
second.expect("commit second operation");
fs::write(root.join("game/parallel.md"), b"# Imported").unwrap();
let local_paths = vec!["game/parallel.md".to_string()];
let (_, _, imported) = crate::tests::with_conversation_write_contention(
root,
async {
tokio::try_join!(
derive_local_project_resource_at(requests[0].clone()),
derive_local_project_resource_at(requests[1].clone()),
crate::commands::import_local_project_assets_for_agent_in_blocking_pool(
root,
&local_paths,
None,
),
)
},
|| {},
)
.await
.expect("并行导入和资源提交必须等待后成功");
let committed = read_existing_manifest_for_project(root).expect("read committed manifest");
assert!(committed
.assets
.iter()
.any(|asset| asset.id == imported.assets[0].id));
for request in &requests {
assert_eq!(
committed
@@ -6535,10 +6585,185 @@ mod tests {
read_game_creator_agent_runtime_project_revision(root)
.expect("read committed revision")
.revision,
2
3
);
}
fn staged_background_removal_for_contention(root: &Path) -> DeriveLocalProjectResourceInput {
init_local_game_project_at(root, PROJECT_ID, "资源提交争用").unwrap();
let uploaded =
upload_local_asset_at(root, "source.png", "image/png", &resource_editor_test_png())
.unwrap();
let manifest = read_existing_manifest_for_project(root).unwrap();
let mut request = input(
root,
Uuid::new_v4().to_string(),
LocalProjectResourceEditKind::BackgroundRemoval,
format!("asset:{}", uploaded.id),
);
request.source_asset_id = Some(uploaded.id);
request.source_path = Some(uploaded.local_path);
request.source_media_type = Some("image/png".into());
let source = resolve_resource_edit_source(root, &manifest, &request).unwrap();
let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::MediaDownloaded);
ledger.request_fingerprint = resource_edit_request_fingerprint(
&request,
&source,
&request.prompt,
&request.asset_name,
)
.unwrap();
ledger.remote_operation_id = Some("completed-background-removal".into());
ledger.remote_object_key = Some("generated/completed.png".into());
ledger.staged_media_type = Some("image/png".into());
ledger.staged_extension = Some("png".into());
write_resource_edit_ledger(root, &ledger).unwrap();
write_resource_edit_staging(root, &request.operation_id, &resource_editor_test_png())
.unwrap();
request
}
#[tokio::test]
async fn downloaded_resource_commit_and_replay_wait_without_new_remote_work() {
let _session_guard = clear_test_platform_session();
let directory = tempfile::tempdir().unwrap();
let root = directory.path();
let request = staged_background_removal_for_contention(root);
let before = read_game_creator_agent_runtime_project_revision(root)
.unwrap()
.revision;
// 不提供远端凭据:下载完成后的提交与重放必须只使用原账本和 staging。
let result = crate::tests::with_conversation_write_contention(
root,
derive_local_project_resource_at(request.clone()),
|| {},
)
.await
.unwrap();
assert_eq!(result.operation_id, request.operation_id);
assert_eq!(result.committed_project_revision, before + 1);
let ledger = read_resource_edit_ledger(root, &request.operation_id)
.unwrap()
.unwrap();
assert_eq!(ledger.phase, ResourceEditLedgerPhase::Committed);
assert_eq!(ledger.idempotency_key, request.idempotency_key);
assert_eq!(
ledger.remote_operation_id.as_deref(),
Some("completed-background-removal")
);
let replay = crate::tests::with_conversation_write_contention(
root,
derive_local_project_resource_at(request.clone()),
|| {},
)
.await
.unwrap();
assert_eq!(replay.operation_id, result.operation_id);
assert_eq!(
replay.committed_project_revision,
result.committed_project_revision
);
assert_eq!(replay.asset, result.asset);
assert_eq!(
replay
.manifest
.assets
.iter()
.filter(|asset| asset.id == format!("edit-{}", request.operation_id))
.count(),
1
);
}
#[tokio::test]
async fn cancelled_resource_commit_wait_keeps_operation_and_project_guards() {
let _session_guard = clear_test_platform_session();
let directory = tempfile::tempdir().unwrap();
let root = directory.path();
let request = staged_background_removal_for_contention(root);
let operation_lock = resource_edit_operation_lock(root, &request.operation_id).unwrap();
let project_lock = resource_edit_project_mutation_lock(root).unwrap();
let before = read_game_creator_agent_runtime_project_revision(root)
.unwrap()
.revision;
crate::tests::with_conversation_write_contention(
root,
async {
let mut commit = Box::pin(derive_local_project_resource_at(request.clone()));
assert!(futures::poll!(commit.as_mut()).is_pending());
drop(commit);
// JoinHandle 被丢弃后阻塞任务仍会继续,互斥必须随任务保留。
assert!(operation_lock.try_lock().is_err());
assert!(project_lock.try_lock().is_err());
let _finished =
tokio::time::timeout(Duration::from_secs(10), operation_lock.lock())
.await
.expect("阻塞提交应在项目锁释放后完成");
Ok(())
},
|| {},
)
.await
.unwrap();
assert_eq!(
read_resource_edit_ledger(root, &request.operation_id)
.unwrap()
.unwrap()
.phase,
ResourceEditLedgerPhase::Committed
);
assert_eq!(
read_game_creator_agent_runtime_project_revision(root)
.unwrap()
.revision,
before + 1
);
assert!(project_lock.try_lock().is_ok());
}
#[tokio::test]
async fn resource_commit_timeout_preserves_staging_and_resumes_original_operation() {
let _session_guard = clear_test_platform_session();
let directory = tempfile::tempdir().unwrap();
let root = directory.path();
let request = staged_background_removal_for_contention(root);
let before = read_game_creator_agent_runtime_project_revision(root)
.unwrap()
.revision;
let ledger_before = read_resource_edit_ledger(root, &request.operation_id).unwrap();
let lock = acquire_project_write_lock(root, "conversation.write").unwrap();
let started = std::time::Instant::now();
let error = derive_local_project_resource_at(request.clone())
.await
.unwrap_err();
assert!(
started.elapsed() >= Duration::from_secs(9),
"未使用完整有界等待:{error}"
);
assert!(error.contains("commandId=conversation.write"), "{error}");
assert!(error.contains("ownerIsSelf=true"), "{error}");
assert_eq!(
read_resource_edit_ledger(root, &request.operation_id).unwrap(),
ledger_before
);
assert_eq!(
read_resource_edit_staging(root, &request.operation_id).unwrap(),
resource_editor_test_png()
);
assert_eq!(
read_game_creator_agent_runtime_project_revision(root)
.unwrap()
.revision,
before
);
drop(lock);
let recovered = derive_local_project_resource_at(request.clone())
.await
.unwrap();
assert_eq!(recovered.operation_id, request.operation_id);
assert_eq!(recovered.committed_project_revision, before + 1);
}
#[test]
fn edit_kind_provenance_uses_stable_kebab_case_values() {
assert_eq!(
@@ -16,6 +16,44 @@ const MANIFEST_INVALIDATION_RELAY_TEST_ACCEPT_TIMEOUT: Duration = Duration::from
const MANIFEST_INVALIDATION_RELAY_TEST_PAYLOAD_TIMEOUT: Duration = Duration::from_millis(500);
const MANIFEST_INVALIDATION_RELAY_TEST_MAX_BYTES: usize = 64 * 1024;
/// 在另一线程持有真实项目锁;单线程 runtime 必须能在释放锁前推进心跳。
pub(crate) async fn with_conversation_write_contention<T>(
root: &Path,
operation: impl std::future::Future<Output = Result<T, String>>,
while_waiting: impl FnOnce(),
) -> Result<T, String> {
let root = root.to_path_buf();
let (ready_tx, ready_rx) = std::sync::mpsc::channel();
let (release_tx, release_rx) = std::sync::mpsc::channel();
let holder = std::thread::spawn(move || {
let _lock = acquire_project_write_lock(&root, "conversation.write").unwrap();
ready_tx.send(()).unwrap();
// 若回归为 runtime 同步阻塞,限时释放以让断言失败而不是挂死测试。
let _ = release_rx.recv_timeout(Duration::from_secs(5));
});
ready_rx.recv_timeout(Duration::from_secs(5)).unwrap();
let finished = AtomicBool::new(false);
let started = Instant::now();
let (result, ()) = tokio::join!(
async {
let result = operation.await;
finished.store(true, Ordering::SeqCst);
result
},
async {
tokio::time::sleep(Duration::from_millis(100)).await;
let elapsed = started.elapsed();
let completed_while_locked = finished.load(Ordering::SeqCst);
while_waiting();
let _ = release_tx.send(());
assert!(elapsed < Duration::from_secs(3), "项目锁等待阻塞了 runtime");
assert!(!completed_while_locked, "项目锁释放前操作已返回");
}
);
holder.join().unwrap();
result
}
// 委派测试显式持有与正式 action 执行链相同的项目写锁,随后调用现役核心。
fn observe_agent_runtime_agent_delegate(
root: &Path,
@@ -122,7 +122,7 @@ pub(crate) async fn run_ui_design_doc_workflow(
)
.await?;
// 切图登记按路径复用 manifest 条目;失败只回报说明,不回滚已登记资源。
let cut_images = register_cut_image_sprites(root, &dto)?;
let cut_images = register_cut_image_sprites(root, &dto).await?;
let mut backfill_errors = cut_images.errors;
progress.state = add_sprite_assets(&progress.state, &cut_images.sprites)?;
backfill_errors.extend(apply_separation(
@@ -6,7 +6,7 @@
//! 说明,不回滚已经登记成功的资源。
use super::normalize_cut_image_path;
use crate::import_local_project_assets_for_agent;
use crate::import_local_project_assets_for_agent_in_blocking_pool;
use crate::ui_editor::commands::SeparationDTO;
use crate::ui_editor::resource::sprite::SpriteAsset;
use crate::ui_editor::utils::SpriteAssetId;
@@ -29,7 +29,7 @@ pub(crate) struct CutImageSprites {
pub(crate) errors: Vec<String>,
}
pub(crate) fn register_cut_image_sprites(
pub(crate) async fn register_cut_image_sprites(
root: &Path,
dto: &SeparationDTO,
) -> Result<CutImageSprites, String> {
@@ -43,7 +43,8 @@ pub(crate) fn register_cut_image_sprites(
}
let mut imported_by_path: Vec<(String, ImportedAsset)> = Vec::new();
for batch in unique_paths.chunks(CUT_IMAGE_IMPORT_BATCH_SIZE) {
let imported = import_local_project_assets_for_agent(root, batch)?;
let imported =
import_local_project_assets_for_agent_in_blocking_pool(root, batch, None).await?;
if imported.assets.len() != batch.len() {
return Err(format!(
"本地资源登记结果数量不匹配:请求 {} 个,返回 {} 个",
@@ -140,6 +141,36 @@ mod tests {
}
}
#[tokio::test]
async fn cut_image_registration_waits_without_blocking_worker() {
let project = tempfile::tempdir().unwrap();
let root = project.path();
crate::init_local_game_project_at(root, "cut-import-wait", "切图导入等待").unwrap();
image::DynamicImage::new_rgba8(2, 2)
.save(root.join("game/cut.png"))
.unwrap();
let dto = SeparationDTO {
bound_nodes: vec![bound("game/cut.png"), bound("game/cut.png")],
problematic_nodes: Vec::new(),
};
let result = crate::tests::with_conversation_write_contention(
root,
register_cut_image_sprites(root, &dto),
|| {},
)
.await
.unwrap();
assert_eq!(result.sprites.len(), 1);
assert!(result.by_path.contains_key("game/cut.png"));
assert!(result.errors.is_empty());
assert_eq!(
crate::read_game_creator_agent_runtime_project_revision(root)
.unwrap()
.revision,
1
);
}
#[test]
fn unique_paths_normalize_separators_and_drop_duplicates() {
let dto = SeparationDTO {
@@ -8726,6 +8726,13 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 边界:锁定这些不变量的既有用例(`project_tools` / `command_runtime` / `parallel_actions` / `runtime_state` / `response_stream` / `direct_tool_bridge` / `ui_editor::persistence`)不得为了让锁语义通过而改写;用「同线程自持锁」模拟「另一个写者」的两条用例改为**在另一条线程持锁**,断言语义不变。同进程跨线程重入(持锁链在 `await` / `spawn_blocking` 后于其它线程再取锁)仍会等满预算,出现现场时按 2026-08-27 的既有处置改用 `*_locked` 入口,不放宽判据。
- 关联文档:[项目客户端占用锁收敛里程碑](../plans/【里程碑】项目客户端占用锁收敛-2026-09-14.md)、[踩坑记录](pitfalls.md)。
## 2026-10-01 素材提交复用有界项目锁等待
- 本地素材导入、账户素材下载后的提交、资源编辑资产/版本提交及已完成结果清理复用既有类型化有界等待,跨进程排他、同线程重入和权限分类保持不变。
- 同步等待与事务整体放入阻塞线程池;必须先取得项目锁,再取得平台身份租约,并在等待后验证原 WritePermit。不能在账号互斥锁内等待项目锁,也不能把项目锁 guard 跨线程搬到实际提交线程。
- 资源编辑的 operation/project 异步互斥 guard 随阻塞任务持有,调用方取消等待不能提前释放。原账本、operation、幂等键及 staging 继续用于本地恢复,不因锁争用重复远端生成。
- 关联:Issue #551;行为与证据见 AGC 实施计划“素材导入与资源提交的项目锁等待”。
## 2026-09-14 AGC 图片类生成后台化:提交即返回 + 项目内任务账本 + 本地排队
- 背景:栏目画布图片类入口原先是一条同步 IPC `generate_local_project_asset`,一次调用最长等 35 分钟;提交期间两块生成浮层把 × / 遮罩 / Esc /「取消」全部锁死,用户既关不掉面板也看不到进度。远端图片类生成当时共用 single-flight 输出槽(`standalone_platform_art_generation_runtime_context` 按 `outputPath` 派生 `run_id`)——**同一批次内该槽身份已改为精确动作指纹,见本文件 2026-09-14「放开 AGC 手工图片生成的本地并发」条目。**
@@ -1618,6 +1618,22 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过
- **素材重命名 UI**:新增 `ResourceRenameDialog`,入口在资源卡选中工具条的 `extraActions`(与「分类与标签」并列),只输入新文件名。前端严格按 `deny_unknown_fields` 只传 `{ projectPath, assetId, newFileName }`;成功后复用 `reloadManifestAfterAssetCommand` 这条既有 manifest 重载路径刷新资源卡与 `@` 面板显示名。资料性边界沿用 Rust 侧口径:不改游戏源码里对旧 `assets/<name>` 的引用。
- **过渡 allowlist 收敛**:`check-config.mjs` 的 `allowedUncalledTauriCommands` 删除 `normalize_local_project_raster_resource`——该命令在 C3 之后的选中工具条里已有真实前端调用方,不再是「无人调用」的过渡项。
- **验证**:AGC 前端全量 1080 passed / 4 skipped / 5 failed(5 条失败全部是 C3 交互改造后仍待更新的既有用例,与本包改动无关:`renders immutable manifest versions…`、`keeps the resource toolbar and canvas interactive…`、`loads registered documents, art media, video, and audio…`、`moves historical resource positions…`,以及 `projectResourceLiveIntegration` 的一条选中按钮选择器);本包新增的 25 条测试(标签库 5、筛选条 3、参考图弹窗 6、参考图模型 7、重命名 2、版本判定 5、版本切换 3,其中 5 条落在共享契约与共享组件目录)全部通过;共享美术画布组件 1385 passed;`cargo check --all-targets` 通过;`asset_rename` 9 passed;`npm run check:encoding` 与 `git diff --check` 干净。
## 素材导入与资源提交的项目锁等待
- 本地素材导入、账户素材下载后的本地提交、资源编辑资产提交及其已完成结果恢复必须吸收短暂项目写锁争用;等待沿用现有有界预算与类型化失败分类。耗尽时报告可读取的持锁方身份,身份不可读时明确说明,不推断锁无人持有。Windows 删除挂起与 ACL 的现有终态判据保持不变。
- 同步取锁、等待、提交和释放在同一个阻塞任务内完成,不占用异步 runtime worker。等待不持有平台登录态互斥锁;取得项目锁后再验证账号身份并持有身份租约覆盖本地提交。导入的原始 WritePermit 在等待后重新校验,取消或过期的许可不能提交。
- 项目锁内重新读取 manifest 与 revision,复核项目和资源身份,保持并行提交无更新丢失。跨进程排他、同线程重入和现有残留回收判据不变,不新增同 PID 豁免。
- 资源编辑继续使用原 operation、幂等键、账本和 staging;本地提交争用不得重发远端生成。阻塞提交开始后,operation 与项目提交互斥 guard 归阻塞任务所有,即使等待它的异步调用被取消也不能提前释放它们。网络请求不在项目写锁内执行。
- 不改变 API、DTO、账本 schema 或已有项目数据,不要求迁移;其它零等待入口按各自专题处理。本节覆盖下文旧事项中本地/账户素材导入与资源提交的部分。
- 验收证据:三条入口在 conversation.write 持锁时等待后成功;等待期间单线程 runtime 心跳继续推进;取消导入零写入;并行提交 manifest/revision 完整;耗尽、权限拒绝、活跨进程锁和残留回收沿用锁测试;远端已完成的资源编辑恢复保持同 operation 且零新增远端请求。真实 Windows 删除挂起行为仍需 Windows 环境验证。
| 行为 | 自动化证据入口 |
| --- | --- |
| 导入等待、账号切换与许可取消 | `commands::agent_asset_import_tests`、`direct_tool_bridge::tests::cancelled_import_write_permit_keeps_files_manifest_and_revision_unchanged` |
| Runtime 与切图登记不阻塞 worker | `runtime_local_import_waits_without_blocking_worker`、`cut_image_registration_waits_without_blocking_worker` |
| 并行提交、超时恢复、取消时互斥生命周期 | `project_mutation_lock_serializes_independent_asset_commits_without_lost_updates`、`resource_commit_timeout_preserves_staging_and_resumes_original_operation`、`cancelled_resource_commit_wait_keeps_operation_and_project_guards` |
| 已下载结果和已提交回包复用、权限与跨进程边界 | `downloaded_resource_commit_and_replay_wait_without_new_remote_work`、`resource_editor::background_removal_tests`、`project_lock_recovery`、`project::write_lock` |
## 2026-09-10 Direct 写通道项目锁等待、持锁方可诊断与权限分类
- `agc_write_file` 是用户直接触发、失败即整轮无法落盘的项目写入通道,原先却用零等待 `acquire_project_write_lock`:任何重叠都在 24-42ms 内被判成“项目正在被其他写操作占用”,而 `file.write / file.patch / file.delete` 等入口用的是约 10 秒有界等待。现统一为 `acquire_game_creator_agent_runtime_project_write_lock_with_wait`:短暂重叠排队等成功,只有预算耗尽才报出带持锁方身份的错误;同一轮并行写多个文件按同一把锁串行。这是 2026-07-22 同一形状修复在 Direct 通道上的补齐,与 2026-08-13 一节“这些结果统一投影为争用并进入既有有界等待”的口径一致。**失败耗时是判据**:几十毫秒说明该入口没等,不是锁没释放。