diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_policy_v2.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_policy_v2.rs index 4245ea33a..90fcf28f1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_policy_v2.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_policy_v2.rs @@ -1222,7 +1222,12 @@ pub(crate) fn persist_planning_policy_output_v2( elapsed_seconds: f64, output: PlanningPolicyOutputV2, ) -> Result { - let _lock = acquire_project_write_lock(root, "planning.v2.policy.persist")?; + // Provider 已经成功返回;落盘是一次性提交点。hydrate / 审批续跑会同时伸手 + // 拿项目锁,无等待取锁会把瞬时争用变成“总控执行失败”。 + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "planning.v2.policy.persist", + )?; let mut session = read_planning_session_v2(root)?.ok_or_else(|| "Planning V2 Session 不存在".to_string())?; if session.session_id != session_id || session.turn_index != turn_index { @@ -1382,7 +1387,12 @@ pub(crate) fn decide_planning_artifact_v2_at( root: &Path, input: DecidePlanningArtifactV2Input, ) -> Result { - let _lock = acquire_project_write_lock(root, "planning.v2.approval")?; + // 审批按钮是一次性意图。修订后续跑和 GUI hydrate 会同时抢同一把项目锁; + // V1 `decide_plan_gdd_at` 已按完整窗口等待,V2 必须同样等过瞬时争用。 + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "planning.v2.approval", + )?; let mut session = read_planning_session_v2(root)?.ok_or_else(|| "Planning V2 Session 不存在".to_string())?; let project_id = read_manifest_for_project(root)?.project_id; @@ -1929,4 +1939,66 @@ mod tests { "第二版守夜者" ); } + + fn hold_project_lock_briefly(root: &Path, hold_millis: u64) -> std::thread::JoinHandle<()> { + let lock_path = root.join(".agent/project.lock"); + let held = serde_json::json!({ + "commandId": "test.hold", + "pid": std::process::id(), + "createdAt": unix_timestamp(), + "nonce": 0, + }); + fs::write( + &lock_path, + serde_json::to_vec(&held).expect("serialize held lock"), + ) + .expect("hold project lock"); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(hold_millis)); + fs::remove_file(&lock_path).expect("release project lock"); + }) + } + + #[test] + fn v2_decision_rides_out_a_briefly_held_project_lock() { + let (_dir, root, session) = v2_persist_fixture(); + let persisted = persist_planning_policy_output_v2( + &root, + "turn-1", + &session.session_id, + session.turn_index, + 1.0, + PlanningPolicyOutputV2::Gdd(sample_gdd_input()), + ) + .expect("persist gdd"); + let artifact = persisted + .current_artifact + .as_ref() + .expect("current artifact"); + let holder = hold_project_lock_briefly(&root, 120); + let decision = decide_planning_artifact_v2_at( + &root, + DecidePlanningArtifactV2Input { + session_id: session.session_id.clone(), + artifact_id: artifact + .get("artifactId") + .and_then(Value::as_str) + .expect("artifactId") + .to_string(), + version: 1, + fingerprint: artifact + .get("fingerprint") + .and_then(Value::as_str) + .expect("fingerprint") + .to_string(), + decision_id: "gdd-response-v2-lock-wait".to_string(), + action: "revise".to_string(), + comment: Some("加强节奏".to_string()), + }, + ) + .expect("审批修改必须等过瞬时锁争用,而不是把失败甩回按钮"); + holder.join().expect("lock holder thread"); + assert_eq!(decision.session.status, "revision_requested"); + assert!(!decision.replayed); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_session_v2.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_session_v2.rs index bf4fd1850..a590421a3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_session_v2.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_session_v2.rs @@ -655,7 +655,12 @@ fn prepare_turn_v2( validate_project_root(root)?; let client_turn_id = validate_client_turn_id(client_turn_id)?; let prompt = validate_prompt(prompt)?; - let _lock = acquire_project_write_lock(root, "planning.v2.turn.start")?; + // 用户提交回答或审批修改意见后的续跑是一次性意图。无等待取锁会把 + // hydrate / 刚结束的审批写盘误判成外部占用,前端再映射成总控失败。 + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "planning.v2.turn.start", + )?; let mut session = match read_planning_session_v2(root)? { Some(session) => session, None => { @@ -955,7 +960,10 @@ fn persist_turn_failure_v2( elapsed_seconds: f64, error: &PlanningErrorV2, ) -> Result { - let _lock = acquire_project_write_lock(root, "planning.v2.turn.fail")?; + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "planning.v2.turn.fail", + )?; let mut session = read_planning_session_v2(root)?.ok_or_else(|| "Planning V2 Session 不存在".to_string())?; if session.session_id != session_id || session.turn_index != turn_index { @@ -1224,7 +1232,10 @@ where } Err(detail) => { let repaired = { - let _lock = acquire_project_write_lock(root, "planning.v2.gdd.reconcile")?; + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "planning.v2.gdd.reconcile", + )?; matches!(reconcile_committed_planning_gdd_v2(root), Ok(Some(_))) }; if repaired { @@ -1363,7 +1374,12 @@ pub(crate) fn hydrate_planning_session_v2( ) -> Result, String> { let root = PathBuf::from(project_path.trim()); enforce_project_permission_policy(&root, "conversation.read")?; - let _lock = acquire_project_write_lock(&root, "planning.v2.hydrate")?; + // GUI 在审批落盘后会立刻重灌。短窗口等过瞬时争用;下一拍轮询还会再跑, + // 不能占满完整写锁等待把面板卡住。 + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_short_wait( + &root, + "planning.v2.hydrate", + )?; let Some(mut session) = read_planning_session_v2(&root)? else { return Ok(None); }; @@ -1478,4 +1494,64 @@ mod tests { "按第 2 个选项做" ); } + + fn hold_project_lock_briefly(root: &Path, hold_millis: u64) -> std::thread::JoinHandle<()> { + let lock_path = root.join(".agent/project.lock"); + let held = serde_json::json!({ + "commandId": "test.hold", + "pid": std::process::id(), + "createdAt": unix_timestamp(), + "nonce": 0, + }); + fs::write( + &lock_path, + serde_json::to_vec(&held).expect("serialize held lock"), + ) + .expect("hold project lock"); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(hold_millis)); + fs::remove_file(&lock_path).expect("release project lock"); + }) + } + + fn v2_revision_session_fixture() -> (tempfile::TempDir, PathBuf, PlanningSessionV2) { + let directory = tempfile::tempdir().expect("create v2 lock wait fixture"); + let root = directory.path().to_path_buf(); + crate::init_local_game_project_at(&root, "project-v2-lock", "V2 锁等待测试") + .expect("init project"); + let project_id = crate::read_manifest_for_project(&root) + .expect("read manifest") + .project_id; + let mut session = new_session_v2(project_id, "gdd".to_string()); + session.status = "revision_requested".to_string(); + session.turn_index = 1; + write_planning_session_v2(&root, &session).expect("write session"); + (directory, root, session) + } + + #[test] + fn turn_start_rides_out_a_briefly_held_project_lock() { + let (_dir, root, _session) = v2_revision_session_fixture(); + let holder = hold_project_lock_briefly(&root, 120); + let start = prepare_turn_v2(&root, "turn-revise-1", "加强节奏", None, false) + .expect("修订续跑必须等过瞬时锁争用,而不是把失败甩回总控"); + holder.join().expect("lock holder thread"); + assert_eq!(start.session.status, "planning"); + assert!(start.replay.is_none()); + } + + #[test] + fn hydrate_rides_out_a_briefly_held_project_lock() { + let (_dir, root, session) = v2_revision_session_fixture(); + let holder = hold_project_lock_briefly(&root, 120); + let hydrated = hydrate_planning_session_v2( + root.to_string_lossy().to_string(), + Some(session.session_id.clone()), + ) + .expect("hydrate 必须等过瞬时锁争用") + .expect("session"); + holder.join().expect("lock holder thread"); + assert_eq!(hydrated.session.session_id, session.session_id); + assert_eq!(hydrated.session.status, "revision_requested"); + } } diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 6a259becf..5b1c90a4a 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -724,7 +724,14 @@ export function App({ try { await hydratePlanningV2Session(targetProjectPath); } catch (error) { - if (requestSequence === planGddHydrateSequenceRef.current) { + // 与旧 hydrate 相同:项目写锁争用是瞬时的。V2 审批修改后会立刻续跑并 + // 重灌,下一拍还能拿到;把占用画进错误位会让刚提交的修改意见看起来失败。 + const transientContention = + String(error).includes('项目正在被其他写操作占用:'); + if ( + !transientContention && + requestSequence === planGddHydrateSequenceRef.current + ) { setPlanGddError(String(error)); } } finally { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 9e11982bb..6c734d32d 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -15,6 +15,14 @@ - 关联文档:相关 PRD、技术文档、提交或 Issue ``` +## 2026-09-05 Planning V2 一次性写路径对齐 V1 的项目锁等待窗口 + +- 背景:V2 审批修改意见后立即 `continue_planning_session_v2`。审批、回合启动、策略落盘和 hydrate 原先无等待取锁,和 GUI 重灌或其它写操作撞车就返回 `项目正在被其他写操作占用`,前端再映射成总控失败。V1 已用完整/短窗口处理同一形状。 +- 决策:V2 审批、回合启动、策略落盘、失败投影和 GDD 认领使用完整等待窗口;V2 hydrate 使用短窗口。不引入可重入项目锁,不放宽失效回收。 +- 影响范围:`planning_policy_v2.rs`、`planning_session_v2.rs`、V2 hydrate 前端瞬时争用处理。 +- 验证方式:Rust 定向测试覆盖短暂占用下的审批、修订续跑和 hydrate。 +- 关联文档:`docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md`、`docs/project-memory/shared-memory/pitfalls.md`。 + ## 2026-09-05 本进程新建 Windows 私有对象不因继承 DACL 自动 UAC - 背景:#211 要求 sidecar 满足当前用户独占、禁止继承的 DACL。新建文件会先继承父目录 ACE,生产路径把这种短暂不合格送进 UAC;`project.lock` 还在独占句柄上 harden。含空格项目路径上提权 ArgumentList 被拆开,修复以 exit 1 失败。GDD 审批改意见因此弹权限,V1 锁创建不会。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index d98a319e1..258e766e7 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2,6 +2,14 @@ > 当前口径:本文件保留可复用的排障经验;历史条目的旧路由、旧版本和已删除文档仅作根因背景,不得据此恢复退役入口。当前命令、路由和 schema 以代码与 `docs/README.md` 为准。 +## 2026-09-05 Planning V2 审批和续跑必须等过项目锁瞬时争用 + +- **现象**:策划 V2 在 GDD 审批提交修改意见后提示 `项目正在被其他写操作占用:...\\.agent\\project.lock`,聊天区再出现 `项目总控 Agent 执行失败,请稍后重试`。 +- **原因**:V1 `decide_plan_gdd_at` / hydrate 已按完整或短窗口等待项目锁。V2 的审批、回合启动、策略落盘和 hydrate 直接 `acquire_project_write_lock`,与 GUI 重灌、刚结束的审批写盘或后台扫描撞车就立刻失败。修订后续跑走 `continue_planning_session_v2`,失败被前端写进总控错误位。这不是锁没释放,也不是 UAC。 +- **处理**:一次性用户意图(审批、回合启动、策略落盘、失败投影、GDD 认领)走完整等待窗口;V2 hydrate 走短窗口。前端 V2 hydrate 对锁争用保持上一份状态,不把瞬时占用画进审批卡。 +- **排查顺序**:先看错误是否点名 `project.lock` 且发生在提交修改意见或立刻续跑;不要当成总控 Runtime 或 Provider 失败。锁文件在失败后通常已被 Drop 删掉,现场缺文件不否定争用。 +- **验证**:Rust 定向覆盖 V2 审批、修订续跑和 hydrate 等过短暂占用的项目锁。 + ## 2026-09-05 新建项目锁不要把继承 DACL 当成 UAC 事件 - **现象**:策划 V2 在 GDD 审批提交修改意见时弹出权限窗口,目标是 `Documents\Genarrative GameAgent\gameagent-*\.agent\project.lock`,随后 `AGC ACL 提权修复未成功(exit code Some(1))`。 diff --git a/docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md b/docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md index 477b4f97a..47423ea9c 100644 --- a/docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md +++ b/docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md @@ -937,6 +937,7 @@ P3 之后的协议修正:V2 不再用正文 JSON 输出问询/GDD;Provider | -------------------------------------------------------- | ----------------------------------------------------------------- | | 继续复用旧 `planning_submit.rs` 导致 Supervisor 身份回流 | V2 使用独立 artifact/approval 模块;只复用通用文件/锁能力 | | 新旧都写 `game/fast_gdd.md` | 同一项目单活跃策划权威;V2/旧路径均使用项目锁和原子写 | +| 审批修改后立刻续跑与 hydrate 抢同一把项目锁 | 一次性用户意图走完整等待窗口,V2 hydrate 走短窗口,对齐 V1;不引入可重入锁 | | 无限会话导致上下文无限膨胀 | 当前先分离完整记录和 ContextBuilder;超预算显式失败,后续再加摘要 | | 未来 MCP/Skill 侵入 GDD 策略 | 能力快照和消息类型在 Runtime 层预留,当前策略不广告、不执行 | | 强制失败导致旧 pending/receipt 不再可继续 | 这是本次明确的退役语义;旧文件只读保留,不迁移、不删除 |