diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs index 5aada9a28..bfa44999b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs @@ -104,6 +104,7 @@ pub(crate) use project_gates::{ prepare_agent_runtime_project_mutation_locked, process_session_completion_blocker_at, project_verification_completion_blocker, project_verification_completion_blocker_at, static_delegate_completion_blocker_at, structured_plan_completion_blocker, + try_acquire_game_creator_agent_runtime_project_write_lock, validate_agent_runtime_pending_verification_gate_before, }; #[cfg(test)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs index 46678ce1e..d16e0dbfe 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs @@ -1868,6 +1868,13 @@ pub(crate) fn acquire_game_creator_agent_runtime_project_write_lock_with_short_w ) } +pub(crate) fn try_acquire_game_creator_agent_runtime_project_write_lock( + root: &Path, + command_id: &str, +) -> Result { + acquire_game_creator_agent_runtime_project_write_lock_within(root, command_id, 1) +} + pub(crate) fn acquire_game_creator_agent_provider_plan_project_write_lock_with_wait( root: &Path, command_id: &str, 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 ced67a86f..ed29b95d3 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 @@ -487,6 +487,12 @@ fn validate_question_v2(question: &PlanningQuestionV2) -> Result<(), String> { Ok(()) } +pub(crate) fn validate_question_value_v2(value: &Value) -> Result<(), String> { + let question = serde_json::from_value::(value.clone()) + .map_err(|error| format!("PLANNING_INVALID_QUESTION: question 结构无效:{error}"))?; + validate_question_v2(&question) +} + fn normalize_v2_decision_state(value: &str) -> Result { match value.trim() { "confirmed" => Ok("confirmed".to_string()), @@ -1440,7 +1446,7 @@ pub(crate) fn decide_planning_artifact_v2_at( return Err("PLANNING_STALE_APPROVAL: 审批引用不是当前最新 GDD".to_string()); } if let Some(existing) = read_approval_v2(root, input.version)? { - if existing.decision_id == input.decision_id + if existing.session_id == session.session_id && existing.action == input.action && existing.comment == comment { 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 98d366271..f3fd64718 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 @@ -606,15 +606,21 @@ fn existing_turn_result_v2( }) } -fn has_successful_assistant_for_turn(messages: &[PlanningMessageV2], turn_index: u64) -> bool { - messages.iter().any(|message| { +fn successful_assistant_for_turn(messages: &[PlanningMessageV2], turn_index: u64) -> Option<&PlanningMessageV2> { + messages.iter().rev().find(|message| { message.turn_index == turn_index && message.role == "assistant" && message.kind != "error" - && message_text(message).is_some_and(|text| !text.trim().is_empty()) + && (message.kind == "question" + && validate_question_value_v2(&message.payload).is_ok() + || message_text(message).is_some_and(|text| !text.trim().is_empty())) }) } +fn has_successful_assistant_for_turn(messages: &[PlanningMessageV2], turn_index: u64) -> bool { + successful_assistant_for_turn(messages, turn_index).is_some() +} + fn committed_gdd_replay_v2( root: &Path, session: &PlanningSessionV2, @@ -1432,17 +1438,14 @@ pub(crate) fn hydrate_planning_session_v2( ) -> Result, String> { let root = PathBuf::from(project_path.trim()); enforce_project_permission_policy(&root, "conversation.read")?; - // 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 { + let project_id = read_project_id_v2(&root)?; + if planning_v2_is_active(&project_id) { + return Ok(None); + } + let Some(preflight_session) = read_planning_session_v2(&root)? else { return Ok(None); }; - let project_id = read_project_id_v2(&root)?; - if session.project_id != project_id { + if preflight_session.project_id != project_id { return Err("Planning V2 Session projectId 与当前项目不一致".to_string()); } if let Some(session_id) = session_id @@ -1450,14 +1453,34 @@ pub(crate) fn hydrate_planning_session_v2( .map(str::trim) .filter(|value| !value.is_empty()) { - if session.session_id != session_id { + if preflight_session.session_id != session_id { return Err("Planning V2 Session ID 不匹配".to_string()); } } + let _lock = match try_acquire_game_creator_agent_runtime_project_write_lock( + &root, + "planning.v2.hydrate", + ) { + Ok(lock) => lock, + Err(error) if error.starts_with("项目正在被其他写操作占用:") => return Ok(None), + Err(error) => return Err(error), + }; + let Some(mut session) = read_planning_session_v2(&root)? else { + return Ok(None); + }; if session.status == "planning" && !planning_v2_is_active(&session.project_id) { let messages = read_planning_messages_v2(&root)?; - if has_successful_assistant_for_turn(&messages, session.turn_index) { + if let Some(message) = successful_assistant_for_turn(&messages, session.turn_index) { session.status = "awaiting_user".to_string(); + if message.kind == "question" { + session.current_question = Some(message.payload.clone()); + let count = messages.iter().filter(|candidate| { + candidate.role == "assistant" && candidate.kind == "question" + && candidate.turn_index <= session.turn_index + && validate_question_value_v2(&candidate.payload).is_ok() + }).map(|candidate| candidate.turn_index).collect::>().len() as u64; + session.question_count = session.question_count.max(count); + } session.last_error = None; } else { session.status = "provider_failed".to_string(); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 729822e03..782a3bb01 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8165,3 +8165,8 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 保留项:V2 与 V1 共用的 GDD 数据模型收敛到 `runtime_protocol/planning_gdd_model.rs`;`.agent/planning/` 旧文件不迁移、不删除,旧 `fast_gdd.md` 仍可只读打开;前端 `PROJECT_SUPERVISOR_PLAN_SOURCE` 字符串与 `GddApprovalCard.tsx`(`PlanGddSurface`)是 V2 现役的适配/展示面,不属于退役对象。 - 旧 sidecar 中带已删字段(`clarification`、`continuation`、`requestId`、`turnId`、`pendingApproval` 等)的记录会因 `deny_unknown_fields` 拒绝反序列化,这是退役语义的一部分,不做迁移。 - 测试基线说明:收尾时测试套件存在 25 个既有失败(mock LLM 时序敏感类,HEAD worktree 对照验证与本次无关),后续清理时不要误记到本次退役头上。 +## 2026-09-08 Planning V2 正常 run 优先的恢复旁路 + +- 恢复只利用已经落盘的合法 question、GDD 和 approval receipt;不调用 Provider、不要求模型额外输出恢复字段、不设置复杂状态机或自动重试。 +- 正常 run 进行时不读取或写入其 Planning V2 文件,也不增加文件锁或等待;无活跃 run 时恢复只做一次非阻塞锁尝试,竞争即退出,交给既有轮询。 +- question 恢复为成功结果,approval 重放复用 receipt 原始 decisionId。 diff --git a/docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md b/docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md index 4700531ac..c0f35415a 100644 --- a/docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md +++ b/docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md @@ -475,6 +475,10 @@ game/fast_gdd.md `gdd.v{N}.json` 的 create-only 写入是提交点。index、`game/fast_gdd.md`、conversation 和 session 指针都是投影:任一投影失败不得回滚已创建的 GDD,也不得用新的 UUID/时间戳重写同一版本。hydrate 与同一回合重试必须认领 session 指针的下一个连续版本并补投影;只有磁盘上还不存在该版本文件时,才根据本轮入参新建。 +### 6.1.1 正常 run 优先的恢复旁路原则 + +崩溃恢复只补齐已经落盘的事实,不得干扰正常 run 的可推进性。恢复不得增加阻塞性校验、Provider 调用、自动重试、大量 CPU 工作、模型额外输出字段,或正常 run 依赖文件的额外锁与等待。正常 run 活跃或锁竞争时,恢复旁路立即退出,交给既有下一次 hydrate;无活跃 run 时才可做一次有限、幂等的本地投影。 + ### 6.2 V2 GDD 与审批 P0 冻结 V2 GDD 使用 `plan-gdd.v2`,只保存业务内容和 V2 自身身份: