修复打开项目时的同步阻塞

将项目目录检查、manifest、revision 和 Planning V2 hydrate 移到 blocking worker

保留同步核心函数供单元测试复用并更新测试调用

同步更新项目打开链路技术方案
This commit is contained in:
2026-09-13 00:04:17 +08:00
parent 1be6e6756c
commit 7605250697
6 changed files with 62 additions and 20 deletions
@@ -1908,7 +1908,7 @@ mod tests {
)
.expect("first persist");
rewind_session_keep_gdd_file(&root, &session);
let hydrated = hydrate_planning_session_v2(
let hydrated = hydrate_planning_session_v2_sync(
root.to_string_lossy().to_string(),
Some(session.session_id.clone()),
)
@@ -1465,7 +1465,18 @@ async fn run_planning_session_v2_command(
}
#[tauri::command]
pub(crate) fn hydrate_planning_session_v2(
pub(crate) async fn hydrate_planning_session_v2(
project_path: String,
session_id: Option<String>,
) -> Result<Option<PlanningSessionCommandResultV2>, String> {
tauri::async_runtime::spawn_blocking(move || {
hydrate_planning_session_v2_sync(project_path, session_id)
})
.await
.map_err(|error| format!("恢复 Planning V2 后台任务失败:{error}"))?
}
pub(crate) fn hydrate_planning_session_v2_sync(
project_path: String,
session_id: Option<String>,
) -> Result<Option<PlanningSessionCommandResultV2>, String> {
@@ -536,7 +536,15 @@ pub(crate) fn is_local_project_directory_non_empty(project_path: String) -> Resu
}
#[tauri::command]
pub(crate) fn inspect_local_project_directory(
pub(crate) async fn inspect_local_project_directory(
project_path: String,
) -> Result<LocalProjectDirectoryStatus, String> {
tauri::async_runtime::spawn_blocking(move || inspect_local_project_directory_sync(project_path))
.await
.map_err(|error| format!("检查项目目录后台任务失败:{error}"))?
}
pub(crate) fn inspect_local_project_directory_sync(
project_path: String,
) -> Result<LocalProjectDirectoryStatus, String> {
let root = Path::new(project_path.trim());
@@ -840,7 +848,18 @@ mod plan_gdd_markdown_path_tests {
}
#[tauri::command]
pub(crate) fn get_local_game_manifest(
pub(crate) async fn get_local_game_manifest(
project_path: String,
command_id: Option<String>,
) -> Result<GameCreationAppManifest, String> {
tauri::async_runtime::spawn_blocking(move || {
get_local_game_manifest_sync(project_path, command_id)
})
.await
.map_err(|error| format!("读取项目 manifest 后台任务失败:{error}"))?
}
pub(crate) fn get_local_game_manifest_sync(
project_path: String,
command_id: Option<String>,
) -> Result<GameCreationAppManifest, String> {
@@ -756,7 +756,15 @@ pub(crate) fn get_local_game_preview_status_at(
}
#[tauri::command]
pub(crate) fn get_local_game_project_revision(
pub(crate) async fn get_local_game_project_revision(
project_path: String,
) -> Result<LocalGameProjectRevisionStatus, String> {
tauri::async_runtime::spawn_blocking(move || get_local_game_project_revision_sync(project_path))
.await
.map_err(|error| format!("读取项目版本后台任务失败:{error}"))?
}
pub(crate) fn get_local_game_project_revision_sync(
project_path: String,
) -> Result<LocalGameProjectRevisionStatus, String> {
get_local_game_project_revision_at(Path::new(project_path.trim()))
@@ -1612,7 +1612,7 @@ fn project_directory_commands_reject_control_characters() {
assert!(is_local_project_directory_non_empty(project_path.clone())
.expect_err("non-empty check should reject control characters")
.contains("控制字符"));
assert!(inspect_local_project_directory(project_path.clone())
assert!(inspect_local_project_directory_sync(project_path.clone())
.expect_err("directory inspect should reject control characters")
.contains("控制字符"));
assert!(
@@ -1648,7 +1648,7 @@ fn project_directory_non_empty_check_reports_existing_content() {
#[test]
fn project_directory_status_distinguishes_missing_file_and_dir() {
let root = unique_project_path();
let missing = inspect_local_project_directory(root.to_string_lossy().to_string())
let missing = inspect_local_project_directory_sync(root.to_string_lossy().to_string())
.expect("missing status");
assert_eq!(
missing,
@@ -1670,8 +1670,8 @@ fn project_directory_status_distinguishes_missing_file_and_dir() {
);
fs::write(&root, "not a dir").expect("file");
let file_status =
inspect_local_project_directory(root.to_string_lossy().to_string()).expect("file status");
let file_status = inspect_local_project_directory_sync(root.to_string_lossy().to_string())
.expect("file status");
assert!(file_status.exists);
assert!(!file_status.is_directory);
assert!(!file_status.is_game_creator_project);
@@ -1684,8 +1684,8 @@ fn project_directory_status_distinguishes_missing_file_and_dir() {
fs::remove_file(&root).expect("remove file");
fs::create_dir_all(&root).expect("dir");
let dir_status =
inspect_local_project_directory(root.to_string_lossy().to_string()).expect("dir status");
let dir_status = inspect_local_project_directory_sync(root.to_string_lossy().to_string())
.expect("dir status");
assert!(dir_status.exists);
assert!(dir_status.is_directory);
assert!(!dir_status.is_game_creator_project);
@@ -1698,7 +1698,7 @@ fn project_directory_status_distinguishes_missing_file_and_dir() {
fs::create_dir_all(root.join(".agent")).expect("agent dir");
fs::write(root.join(".agent/manifest.json"), "{broken").expect("broken manifest");
let broken_manifest_status =
inspect_local_project_directory(root.to_string_lossy().to_string())
inspect_local_project_directory_sync(root.to_string_lossy().to_string())
.expect("broken manifest status");
assert!(broken_manifest_status.exists);
assert!(broken_manifest_status.is_directory);
@@ -1730,7 +1730,7 @@ fn project_directory_status_distinguishes_missing_file_and_dir() {
.to_string(),
)
.expect("run trace");
let project_status = inspect_local_project_directory(root.to_string_lossy().to_string())
let project_status = inspect_local_project_directory_sync(root.to_string_lossy().to_string())
.expect("project status");
assert!(project_status.exists);
assert!(project_status.is_directory);
@@ -1770,7 +1770,7 @@ fn project_directory_status_reports_workspace_relative_godot_root() {
fs::write(godot_root.join("project.godot"), "[application]\n")
.expect("write nested project.godot");
let status = inspect_local_project_directory(root.to_string_lossy().to_string())
let status = inspect_local_project_directory_sync(root.to_string_lossy().to_string())
.expect("inspect nested Godot workspace");
assert!(status.exists);
@@ -1790,7 +1790,7 @@ fn project_directory_status_rejects_ambiguous_direct_child_godot_projects() {
.expect("write nested project.godot");
}
let error = inspect_local_project_directory(root.to_string_lossy().to_string())
let error = inspect_local_project_directory_sync(root.to_string_lossy().to_string())
.expect_err("ambiguous Godot workspace must fail inspection");
assert!(error.contains("多个 Godot 项目"), "{error}");
@@ -2718,12 +2718,14 @@ fn local_game_manifest_reads_respect_declared_command_policy() {
.expect("write policy");
let project_path = root.to_string_lossy().into_owned();
let status_error = get_local_game_manifest(project_path.clone(), None)
let status_error = get_local_game_manifest_sync(project_path.clone(), None)
.expect_err("default project.status denied");
let asset_error = get_local_game_manifest(project_path.clone(), Some("asset.list".to_string()))
.expect_err("asset.list denied");
let unsupported_error = get_local_game_manifest(project_path, Some("file.read".to_string()))
.expect_err("unsupported manifest command denied");
let asset_error =
get_local_game_manifest_sync(project_path.clone(), Some("asset.list".to_string()))
.expect_err("asset.list denied");
let unsupported_error =
get_local_game_manifest_sync(project_path, Some("file.read".to_string()))
.expect_err("unsupported manifest command denied");
assert!(status_error.contains("项目权限策略拒绝执行:project.status"));
assert!(asset_error.contains("项目权限策略拒绝执行:asset.list"));
@@ -4,6 +4,8 @@
DirectProject 工作区只恢复自身对话,不按专业 Agent 默认任务占位行批量读取旧会话或生成专业 Agent 文本回执。专业 Agent 结果加载 effect 必须以当前 Runtime 模式为边界,并在模式切换时清空旧结果。仍供开发入口使用的 `read_local_conversation` 在 blocking worker 内完整执行权限校验、会话目录解析和历史读取,避免文件访问或锁等待阻塞 Tauri 窗口线程。
项目打开链路的目录检查、manifest 读取、项目 revision 读取和 Planning V2 hydrate 也必须通过 blocking worker 执行;它们可能碰到项目写锁,不能在 Tauri 窗口线程同步等待。
DirectProject 自身的 `read_direct_project_conversation` 也必须在 blocking worker 中执行权限校验、JSONL 历史解析和消息投影,不能因为它只读取一份项目历史就保留同步 Tauri command。
验收覆盖实际 Launcher 打开已有项目:恢复 DirectProject 对话且不调用 `read_local_conversation`;后台读取仍遵守项目权限策略。原生客户端重复打开同一已有项目时验证窗口响应,IPC 测量只记录命令名和耗时,不记录会话内容。