From 931f3eae0a8200ebfa6f7f46cd692b463edb74b2 Mon Sep 17 00:00:00 2001 From: Linghong Date: Mon, 14 Sep 2026 06:07:20 +0000 Subject: [PATCH] =?UTF-8?q?=E5=88=A0=E9=99=A4=E9=80=80=E5=BD=B9=E7=AD=96?= =?UTF-8?q?=E5=88=92=20V2=20Rust=20Runtime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除 Planning V2 协议模块与 Tauri 命令注册 保留新版 Design Agent、做游戏 Agent 与共享运行时 --- .../src-tauri/src/agent/design_runtime.rs | 13 +- .../src-tauri/src/agent/runtime_protocol.rs | 4 - .../runtime_protocol/planning_policy_v2.rs | 2021 ----------------- .../runtime_protocol/planning_session_v2.rs | 1756 -------------- .../src-tauri/src/main.rs | 4 - .../src-tauri/src/project/write_lock.rs | 4 +- ...划】退役策划V2 Rust Runtime清理-2026-09-14.md | 21 + ...碑】退役策划V2 Rust Runtime清理-2026-09-14.md | 38 + 8 files changed, 65 insertions(+), 3796 deletions(-) delete mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_policy_v2.rs delete mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_session_v2.rs create mode 100644 docs/project-memory/plans/【实施计划】退役策划V2 Rust Runtime清理-2026-09-14.md create mode 100644 docs/project-memory/plans/【里程碑】退役策划V2 Rust Runtime清理-2026-09-14.md diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs index 3cd44b4bd..80ba114ca 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs @@ -795,15 +795,10 @@ pub(crate) async fn continue_design_agent_at( .ok_or("策划 Agent 当前正在工作")?; let mut session = match read_design_session(root)? { Some(session) => session, - None => { - if read_planning_session_v2(root)?.is_some() { - return Err("此项目包含旧策划会话,请查看原有记录或在新项目开始五阶段策划".into()); - } - new_design_session( - &project_id, - &load_game_creator_app_config()?.selected_model_id, - ) - } + None => new_design_session( + &project_id, + &load_game_creator_app_config()?.selected_model_id, + ), }; if session.project_id != project_id { return Err("策划会话与当前项目不匹配".into()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs index 65b883879..754a2eb8e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs @@ -8,8 +8,6 @@ mod finalization; mod json_sidecar; mod models; mod planning_gdd_model; -mod planning_policy_v2; -mod planning_session_v2; mod provider_control; mod provider_retry; mod real_e2e_checkpoint; @@ -25,8 +23,6 @@ pub(in crate::agent) use finalization::*; pub(in crate::agent) use json_sidecar::*; pub(in crate::agent) use models::*; pub(crate) use planning_gdd_model::*; -pub(crate) use planning_policy_v2::*; -pub(crate) use planning_session_v2::*; pub(in crate::agent) use provider_control::*; pub(in crate::agent) use provider_retry::*; pub(in crate::agent) use real_e2e_checkpoint::*; 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 deleted file mode 100644 index 4b588acfb..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_policy_v2.rs +++ /dev/null @@ -1,2021 +0,0 @@ -use super::*; - -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::fs; -use std::io::Write; -use std::path::{Path, PathBuf}; -use uuid::Uuid; - -pub(crate) const PLAN_GDD_V2_SCHEMA_VERSION: &str = "plan-gdd.v2"; -pub(crate) const PLAN_GDD_INDEX_V2_SCHEMA_VERSION: &str = "plan-gdd-index.v2"; -pub(crate) const PLAN_APPROVAL_V2_SCHEMA_VERSION: &str = "plan-approval.v2"; -const PLAN_GDD_V2_FINGERPRINT_DOMAIN: &str = "genarrative.plan.gdd.v2"; -const PLAN_APPROVAL_V2_FINGERPRINT_DOMAIN: &str = "genarrative.plan.approval.v2"; -const PLAN_GDD_V2_MAX_BYTES: usize = 64 * 1024; -const PLAN_GDD_V2_MAX_VERSIONS: u32 = 128; -pub(crate) const PLAN_ASK_QUESTION_TOOL_NAME: &str = "plan_ask_question"; -pub(crate) const PLAN_SUBMIT_GDD_TOOL_NAME: &str = "plan_submit_gdd"; - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanningQuestionV2 { - pub id: String, - pub header: String, - pub question: String, - pub options: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanningQuestionOptionV2 { - pub label: String, - pub description: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanningGddDecisionInputV2 { - pub topic: String, - pub state: String, - #[serde(default)] - pub answer_source: Option, - pub answer_summary: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanningPrototypeValidationItemInputV2 { - pub question: String, - pub micro_prototype: String, - pub observation: String, - pub pass_criterion: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanningGddInputV2 { - #[serde(default)] - pub schema_version: Option, - pub game: PlanSubmitGame, - pub decisions: Vec, - pub prototype_validation_items: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanningGddV2 { - pub schema_version: String, - pub project_id: String, - pub gdd_id: String, - pub version: u32, - pub created_at_utc: String, - pub game: PlanGddGame, - pub decisions: Vec, - pub prototype_validation_items: Vec, - pub fingerprint: String, -} - -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct PlanningGddFingerprintValueV2<'a> { - schema_version: &'a str, - project_id: &'a str, - gdd_id: &'a str, - version: u32, - created_at_utc: &'a str, - game: &'a PlanGddGame, - decisions: &'a Vec, - prototype_validation_items: &'a Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct PlanningGddIndexV2 { - schema_version: String, - project_id: String, - latest_version: Option, - entries: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct PlanningGddIndexEntryV2 { - gdd_id: String, - version: u32, - fingerprint: String, - status: String, - created_at_utc: String, - decision_id: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanningApprovalV2 { - pub schema_version: String, - pub project_id: String, - pub session_id: String, - pub artifact_id: String, - pub version: u32, - pub fingerprint: String, - pub decision_id: String, - pub action: String, - pub comment: Option, - pub decided_at_utc: String, - pub receipt_fingerprint: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct DecidePlanningArtifactV2Input { - pub session_id: String, - pub artifact_id: String, - pub version: u32, - pub fingerprint: String, - pub decision_id: String, - pub action: String, - pub comment: Option, -} - -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct PlanningApprovalCommandResultV2 { - pub approval: PlanningApprovalV2, - pub session: PlanningSessionV2, - pub current_artifact: Option, - pub replayed: bool, -} - -#[derive(Clone, Debug)] -pub(crate) enum PlanningPolicyOutputV2 { - Question(PlanningQuestionV2), - Gdd(PlanningGddInputV2), -} - -#[derive(Clone, Debug)] -pub(crate) struct PlanningPolicyPersistedV2 { - pub session: PlanningSessionV2, - pub result: PlanningTurnResultV2, - pub current_artifact: Option, -} - -fn v2_path(root: &Path, relative: &str) -> PathBuf { - root.join(".agent/planning-v2").join(relative) -} - -fn decision_state_schema() -> Value { - serde_json::json!({ - "type": "string", - "enum": ["confirmed", "assumption_pending", "prototype_pending"], - "description": "confirmed=已确认;assumption_pending=Agent 推断待确认;prototype_pending=待原型验证" - }) -} - -pub(crate) fn planning_v2_function_tools() -> Vec { - let decision_state = decision_state_schema(); - vec![ - platform_llm::LlmFunctionTool::new( - PLAN_ASK_QUESTION_TOOL_NAME, - "向用户提出一个关键问题。options 可包含一项「需要原型验证」,表示先做小原型验证。", - serde_json::json!({ - "type": "object", - "additionalProperties": false, - "properties": { - "question": { - "type": "object", - "additionalProperties": false, - "description": "决策卡内容,只问一个最关键的问题", - "properties": { - "id": {"type": "string", "description": "snake_case 问题 id"}, - "header": {"type": "string", "description": "当前要决定的主题"}, - "question": {"type": "string", "description": "问用户的问题正文"}, - "options": { - "type": "array", - "description": "可选方案", - "minItems": 2, - "maxItems": 4, - "items": { - "type": "object", - "additionalProperties": false, - "properties": { - "label": {"type": "string", "description": "选项标签"}, - "description": {"type": "string", "description": "该选项的具体方案"} - }, - "required": ["label", "description"] - } - } - }, - "required": ["id", "header", "question", "options"] - } - }, - "required": ["question"] - }), - ), - platform_llm::LlmFunctionTool::new( - PLAN_SUBMIT_GDD_TOOL_NAME, - "提交完整 GDD。", - serde_json::json!({ - "type": "object", - "additionalProperties": false, - "properties": { - "gdd": { - "type": "object", - "additionalProperties": false, - "description": "完整 GDD,按字段填全,不要增删或改名", - "properties": { - "game": { - "type": "object", - "additionalProperties": false, - "description": "玩法方案", - "properties": { - "title": {"type": "string", "description": "中文标题"}, - "genre": { - "type": "object", - "additionalProperties": false, - "description": "游戏类型", - "properties": { - "primary": {"type": "string", "description": "主类型"}, - "fusion": { - "type": ["string", "null"], - "description": "融合类型,没有则为 null" - } - }, - "required": ["primary"] - }, - "artStyle": { - "type": "object", - "additionalProperties": false, - "description": "美术风格", - "properties": { - "visualType": {"type": "string", "description": "视觉类型"}, - "keywords": { - "type": "array", - "description": "风格关键词", - "minItems": 1, - "maxItems": 5, - "items": {"type": "string"} - }, - "moodAndColor": {"type": "string", "description": "氛围与色彩"}, - "mvpArtBoundary": {"type": "string", "description": "MVP 美术边界"} - }, - "required": ["visualType", "keywords", "moodAndColor", "mvpArtBoundary"] - }, - "oneLiner": {"type": "string", "minLength": 25, "maxLength": 90, "description": "一句话概念(建议 25~90 字;Runtime 实际接受 10~160 字,两者有意不对称,不是缺陷或 bug)"}, - "pillars": { - "type": "array", - "description": "游戏支柱", - "minItems": 1, - "maxItems": 5, - "items": { - "type": "object", - "additionalProperties": false, - "properties": { - "name": {"type": "string", "description": "支柱名称"}, - "playerFeel": {"type": "string", "description": "玩家感受"}, - "mechanism": {"type": "string", "description": "实现机制"}, - "decisionState": decision_state.clone() - }, - "required": ["name", "playerFeel", "mechanism", "decisionState"] - } - }, - "coreLoop": { - "type": "array", - "description": "单局核心循环步骤", - "minItems": 1, - "maxItems": 7, - "items": {"type": "string"} - }, - "targetUsers": { - "type": "object", - "additionalProperties": false, - "description": "目标用户", - "properties": { - "coreUsers": {"type": "string", "description": "核心用户"}, - "preferences": {"type": "string", "description": "用户偏好"}, - "sessionLength": {"type": "string", "description": "单局时长"}, - "referenceGames": { - "type": "array", - "description": "参考游戏,没有则为 []", - "items": {"type": "string"} - } - }, - "required": ["coreUsers", "preferences", "sessionLength", "referenceGames"] - }, - "mvpSystems": { - "type": "array", - "description": "MVP 必须有的系统", - "minItems": 1, - "maxItems": 7, - "items": { - "type": "object", - "additionalProperties": false, - "properties": { - "system": {"type": "string", "description": "系统名称"}, - "minimalFunction": {"type": "string", "description": "最小功能"}, - "whyRequired": {"type": "string", "description": "为什么必须有"}, - "verifyMethod": {"type": "string", "description": "验证方式"}, - "decisionState": decision_state.clone() - }, - "required": [ - "system", - "minimalFunction", - "whyRequired", - "verifyMethod", - "decisionState" - ] - } - }, - "outOfScope": { - "type": "array", - "description": "暂不做的内容", - "minItems": 0, - "maxItems": 10, - "items": {"type": "string"} - }, - "creatorTips": { - "type": "object", - "additionalProperties": false, - "description": "给创作者的提示", - "properties": { - "doFirst": {"type": "string", "description": "先做什么"}, - "deferForNow": {"type": "string", "description": "暂缓什么"}, - "howToVerify": {"type": "string", "description": "如何验证"}, - "expandWhen": {"type": "string", "description": "何时扩展"} - }, - "required": ["doFirst", "deferForNow", "howToVerify", "expandWhen"] - } - }, - "required": [ - "title", - "genre", - "artStyle", - "oneLiner", - "pillars", - "coreLoop", - "targetUsers", - "mvpSystems", - "outOfScope", - "creatorTips" - ] - }, - "decisions": { - "type": "array", - "description": "已确认或待确认的决定", - "minItems": 1, - "items": { - "type": "object", - "additionalProperties": false, - "properties": { - "topic": {"type": "string", "description": "主题"}, - "state": decision_state.clone(), - "answerSource": { - "type": "string", - "description": "来源标记,如 user_freeform、user_choice、agent_inferred" - }, - "answerSummary": {"type": "string", "description": "结论摘要"} - }, - "description": "决定内容", - "required": ["topic", "state", "answerSummary"] - } - }, - "prototypeValidationItems": { - "type": "array", - "description": "无 prototype_pending 时为 [];有则按 decisions 中 prototype_pending 决定的顺序填写", - "items": { - "type": "object", - "additionalProperties": false, - "properties": { - "question": {"type": "string", "description": "要验证的问题"}, - "microPrototype": {"type": "string", "description": "最小原型做法"}, - "observation": {"type": "string", "description": "观察什么"}, - "passCriterion": {"type": "string", "description": "通过标准"} - }, - "required": ["question", "microPrototype", "observation", "passCriterion"] - } - } - }, - "required": ["game", "decisions", "prototypeValidationItems"] - } - }, - "required": ["gdd"] - }), - ), - ] -} - -fn parse_tool_arguments(raw: &Value) -> Result { - match raw { - Value::String(text) => serde_json::from_str(text) - .map_err(|error| format!("PLANNING_INVALID_OUTPUT: 工具参数不是合法 JSON:{error}")), - Value::Object(_) => Ok(raw.clone()), - _ => Err("PLANNING_INVALID_OUTPUT: 工具参数必须是 JSON object".to_string()), - } -} - -pub(crate) fn parse_planning_policy_output_v2( - result: &PlanningTurnResultV2, -) -> Result { - let calls = result - .payload - .get("toolCalls") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - if calls.len() != 1 { - return Err( - "PLANNING_INVALID_OUTPUT: 必须且只能调用 plan_ask_question 或 plan_submit_gdd 其中一个,不要在正文输出 JSON" - .to_string(), - ); - } - let call = &calls[0]; - let name = call.get("name").and_then(Value::as_str).unwrap_or(""); - let args = parse_tool_arguments(call.get("arguments").unwrap_or(&Value::Null))?; - match name { - PLAN_ASK_QUESTION_TOOL_NAME => { - let question_value = args.get("question").cloned().ok_or_else(|| { - "PLANNING_INVALID_QUESTION: plan_ask_question 缺少 question 参数".to_string() - })?; - let question = - serde_json::from_value::(question_value).map_err(|error| { - format!("PLANNING_INVALID_QUESTION: question 结构无效:{error}") - })?; - validate_question_v2(&question)?; - Ok(PlanningPolicyOutputV2::Question(question)) - } - PLAN_SUBMIT_GDD_TOOL_NAME => { - let gdd_value = args - .get("gdd") - .cloned() - .ok_or_else(|| "PLANNING_INVALID_GDD: plan_submit_gdd 缺少 gdd 参数".to_string())?; - let gdd = serde_json::from_value::(gdd_value) - .map_err(|error| format!("PLANNING_INVALID_GDD: GDD 结构无效:{error}"))?; - Ok(PlanningPolicyOutputV2::Gdd(gdd)) - } - _ => Err(format!("PLANNING_INVALID_OUTPUT: 未知策划工具:{name}")), - } -} - -fn validate_question_v2(question: &PlanningQuestionV2) -> Result<(), String> { - let id = &question.id; - if id.len() > 32 - || !id.chars().enumerate().all(|(index, value)| { - (index == 0 && value.is_ascii_lowercase()) - || (index > 0 - && (value.is_ascii_lowercase() || value.is_ascii_digit() || value == '_')) - }) - { - return Err("PLANNING_INVALID_QUESTION: question.id 必须是 snake_case".to_string()); - } - validate_text(&question.id, "question.id", 1, 32).map_err(|error| error.to_string())?; - validate_text(&question.header, "question.header", 1, 120) - .map_err(|error| error.to_string())?; - validate_text(&question.question, "question.question", 1, 400) - .map_err(|error| error.to_string())?; - if !(2..=4).contains(&question.options.len()) { - return Err("PLANNING_INVALID_QUESTION: options 必须有 2~4 项".to_string()); - } - let mut labels = std::collections::BTreeSet::new(); - for option in &question.options { - validate_text(&option.label, "question.options.label", 1, 80) - .map_err(|error| error.to_string())?; - validate_text(&option.description, "question.options.description", 1, 400) - .map_err(|error| error.to_string())?; - if !labels.insert(option.label.as_str()) { - return Err("PLANNING_INVALID_QUESTION: options.label 不能重复".to_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()), - "assumption_pending" => Ok("assumption_pending".to_string()), - "prototype_pending" => Ok("prototype_pending".to_string()), - value => Err(format!( - "PLANNING_INVALID_GDD: decision state 无效:{value}" - )), - } -} - -fn normalize_answer_source(state: &str, source: Option) -> Result { - let state = normalize_v2_decision_state(state)?; - let fallback = match state.as_str() { - "confirmed" => "user_freeform".to_string(), - "assumption_pending" => "agent_inferred".to_string(), - "prototype_pending" => "user_option".to_string(), - _ => unreachable!("normalize_v2_decision_state returned an unknown state"), - }; - let source = source - .map(|value| value.trim().to_string()) - .filter(|value| { - matches!( - value.as_str(), - "user_option" | "user_freeform" | "user_revision" | "agent_inferred" - ) - }) - .unwrap_or(fallback); - Ok(match state.as_str() { - // assumption_pending 的含义就是“不是用户明确决定,而是 Agent 推断”, - // 因此无论模型是否漏填/错填来源,都统一落成 agent_inferred。 - "assumption_pending" => "agent_inferred".to_string(), - // agent_inferred 不能与 confirmed 或 prototype_pending 自相矛盾;这两类 - // 状态分别回退到最接近的用户来源,但不把来源不一致当作阻断错误。 - "confirmed" if source == "agent_inferred" => "user_freeform".to_string(), - "prototype_pending" if source == "agent_inferred" => "user_option".to_string(), - _ => source, - }) -} - -fn planning_gdd_game_from_input(game: &PlanSubmitGame) -> Result { - Ok(PlanGddGame { - title: game.title.clone(), - genre: game.genre.clone(), - art_style: game.art_style.clone(), - one_liner: game.one_liner.clone(), - pillars: game - .pillars - .iter() - .map(|value| { - Ok(PlanPillar { - name: value.name.clone(), - player_feel: value.player_feel.clone(), - mechanism: value.mechanism.clone(), - decision_state: normalize_v2_decision_state(&value.decision_state)?, - basis: None, - }) - }) - .collect::, String>>()?, - core_loop: game.core_loop.clone(), - target_users: game.target_users.clone(), - platform_facts: fixed_plan_platform_facts(), - mvp_systems: game - .mvp_systems - .iter() - .map(|value| { - Ok(PlanMvpSystem { - system: value.system.clone(), - minimal_function: value.minimal_function.clone(), - why_required: value.why_required.clone(), - verify_method: value.verify_method.clone(), - decision_state: normalize_v2_decision_state(&value.decision_state)?, - basis: None, - }) - }) - .collect::, String>>()?, - out_of_scope: game.out_of_scope.clone(), - creator_tips: game.creator_tips.clone(), - }) -} - -fn validate_v2_game(game: &PlanGddGame) -> Result<(), String> { - // `validate_plan_game` 属于旧存储模块;V2 先独立校验自己的状态枚举,再用 - // 中性的 confirmed 占位调用旧结构校验器。V2 输入、产物和 Markdown 永远使用 - // assumption_pending,不把 V1 的默认值语义带入 V2。 - let mut storage_compatible = game.clone(); - for pillar in &mut storage_compatible.pillars { - normalize_v2_decision_state(&pillar.decision_state)?; - pillar.decision_state = "confirmed".to_string(); - } - for system in &mut storage_compatible.mvp_systems { - normalize_v2_decision_state(&system.decision_state)?; - system.decision_state = "confirmed".to_string(); - } - validate_plan_game(&storage_compatible) - .map_err(|error| format!("PLANNING_INVALID_GDD: {error}")) -} - -fn validate_v2_decision_content( - index: usize, - topic: &str, - state: &str, - answer_source: Option, - answer_summary: &str, -) -> Result<(), String> { - validate_text(topic, &format!("decisions[{index}].topic"), 1, 400) - .map_err(|error| error.to_string())?; - normalize_v2_decision_state(state)?; - normalize_answer_source(state, answer_source)?; - validate_text( - answer_summary, - &format!("decisions[{index}].answerSummary"), - 1, - if index == 0 { - PLAN_INITIAL_REQUEST_MAX_CHARS - } else { - PLAN_DECISION_ANSWER_SUMMARY_MAX_CHARS - }, - ) - .map_err(|error| error.to_string())?; - Ok(()) -} - -fn validate_v2_prototype_content( - question: &str, - micro_prototype: &str, - observation: &str, - pass_criterion: &str, -) -> Result<(), String> { - for (label, value) in [ - ("question", question), - ("microPrototype", micro_prototype), - ("observation", observation), - ("passCriterion", pass_criterion), - ] { - validate_text(value, &format!("prototypeValidationItems.{label}"), 1, 800) - .map_err(|error| error.to_string())?; - } - Ok(()) -} - -fn validate_v2_decision_inputs( - decisions: &[PlanningGddDecisionInputV2], - prototype_items: &[PlanningPrototypeValidationItemInputV2], -) -> Result<(), String> { - if !(1..=64).contains(&decisions.len()) { - return Err("PLANNING_INVALID_GDD: decisions 必须有 1~64 项".to_string()); - } - for (index, decision) in decisions.iter().enumerate() { - validate_v2_decision_content( - index, - &decision.topic, - &decision.state, - decision.answer_source.clone(), - &decision.answer_summary, - )?; - } - let prototype_count = decisions - .iter() - .filter(|decision| decision.state.trim() == "prototype_pending") - .count(); - if prototype_items.len() > 16 || prototype_count != prototype_items.len() { - return Err("PLANNING_INVALID_GDD: prototypeValidationItems 必须逐项对应 prototype_pending 决定且最多 16 项".to_string()); - } - for item in prototype_items { - validate_v2_prototype_content( - &item.question, - &item.micro_prototype, - &item.observation, - &item.pass_criterion, - )?; - } - Ok(()) -} - -fn validate_v2_durable_decisions( - decisions: &[PlanDecision], - prototype_items: &[PlanPrototypeValidationItem], -) -> Result<(), String> { - // Provider 输入不携带 ID;这里只校验 Runtime 已生成或已持久化产物的 - // 内部引用完整性,避免把模型负责的内容校验与产物身份校验混在一起。 - if !(1..=64).contains(&decisions.len()) { - return Err("PLANNING_INVALID_GDD: decisions 必须有 1~64 项".to_string()); - } - - let mut prototype_ids = std::collections::BTreeSet::new(); - for (index, decision) in decisions.iter().enumerate() { - validate_v2_decision_content( - index, - &decision.topic, - &decision.state, - Some(decision.answer_source.clone()), - &decision.answer_summary, - )?; - if decision.state == "prototype_pending" { - prototype_ids.insert(decision.id.as_str()); - } - } - let mut item_ids = std::collections::BTreeSet::new(); - if prototype_ids.len() != prototype_items.len() || prototype_items.len() > 16 { - return Err("PLANNING_INVALID_GDD: prototypeValidationItems 必须逐项对应 prototype_pending 决定且最多 16 项".to_string()); - } - for item in prototype_items { - if !item_ids.insert(item.id.as_str()) || !prototype_ids.contains(item.id.as_str()) { - return Err("PLANNING_INVALID_GDD: prototypeValidationItems.id 必须与 prototype_pending 决定双射".to_string()); - } - validate_v2_prototype_content( - &item.question, - &item.micro_prototype, - &item.observation, - &item.pass_criterion, - )?; - } - Ok(()) -} - -fn runtime_decision_id(index: usize) -> String { - if index == 0 { - "initial-request".to_string() - } else { - format!("decision-{index}") - } -} - -fn build_gdd_v2( - project_id: &str, - version: u32, - input: PlanningGddInputV2, -) -> Result { - validate_planning_policy_output_v2(&PlanningPolicyOutputV2::Gdd(input.clone()))?; - let game = planning_gdd_game_from_input(&input.game)?; - validate_v2_game(&game)?; - let decisions = input - .decisions - .into_iter() - .enumerate() - .map(|(index, value)| { - Ok(PlanDecision { - id: runtime_decision_id(index), - topic: value.topic, - state: normalize_v2_decision_state(&value.state)?, - answer_source: normalize_answer_source(&value.state, value.answer_source)?, - // round 是 Runtime 元数据:首项代表初始需求,其余决策按 - // 输出顺序归入当前最多 3 轮的澄清区间,不接受模型自行指定。 - round: (index as u32).min(3), - answer_summary: value.answer_summary, - basis: None, - }) - }) - .collect::, String>>()?; - let prototype_ids = decisions - .iter() - .filter(|decision| decision.state == "prototype_pending") - .map(|decision| decision.id.clone()) - .collect::>(); - let prototype_validation_items = input - .prototype_validation_items - .into_iter() - .zip(prototype_ids) - .map(|(value, id)| PlanPrototypeValidationItem { - id, - question: value.question, - micro_prototype: value.micro_prototype, - observation: value.observation, - pass_criterion: value.pass_criterion, - }) - .collect::>(); - let mut gdd = PlanningGddV2 { - schema_version: PLAN_GDD_V2_SCHEMA_VERSION.to_string(), - project_id: project_id.to_string(), - gdd_id: format!("gdd-{}", Uuid::new_v4()), - version, - created_at_utc: current_plan_timestamp_utc(), - game, - decisions, - prototype_validation_items, - fingerprint: String::new(), - }; - gdd.fingerprint = fingerprint_gdd_v2(&gdd)?; - Ok(gdd) -} - -pub(crate) fn validate_planning_policy_output_v2( - output: &PlanningPolicyOutputV2, -) -> Result<(), String> { - match output { - PlanningPolicyOutputV2::Question(question) => validate_question_v2(question), - PlanningPolicyOutputV2::Gdd(input) => { - validate_v2_decision_inputs(&input.decisions, &input.prototype_validation_items)?; - let game = planning_gdd_game_from_input(&input.game)?; - validate_v2_game(&game) - } - } -} - -fn fingerprint_gdd_v2(value: &PlanningGddV2) -> Result { - typed_serde_fingerprint( - PLAN_GDD_V2_FINGERPRINT_DOMAIN, - &PlanningGddFingerprintValueV2 { - schema_version: &value.schema_version, - project_id: &value.project_id, - gdd_id: &value.gdd_id, - version: value.version, - created_at_utc: &value.created_at_utc, - game: &value.game, - decisions: &value.decisions, - prototype_validation_items: &value.prototype_validation_items, - }, - ) - .map_err(|error| error.to_string()) -} - -fn validate_gdd_v2(value: &PlanningGddV2, project_id: &str) -> Result<(), String> { - if value.schema_version != PLAN_GDD_V2_SCHEMA_VERSION - || value.project_id != project_id - || !(1..=PLAN_GDD_V2_MAX_VERSIONS).contains(&value.version) - || !is_typed_fingerprint(&value.fingerprint) - { - return Err("PLANNING_INVALID_GDD: V2 GDD 身份或 schema 无效".to_string()); - } - validate_uuid_prefixed(&value.gdd_id, "gdd-", "gddId").map_err(|error| error.to_string())?; - validate_timestamp(&value.created_at_utc, "createdAtUtc").map_err(|error| error.to_string())?; - validate_v2_game(&value.game)?; - validate_v2_durable_decisions(&value.decisions, &value.prototype_validation_items)?; - let expected = fingerprint_gdd_v2(value)?; - if expected != value.fingerprint { - return Err("PLANNING_INVALID_GDD: GDD fingerprint 不匹配".to_string()); - } - Ok(()) -} - -fn canonical_gdd_v2_bytes(value: &PlanningGddV2) -> Result, String> { - let bytes = serde_json::to_vec(value).map_err(|error| error.to_string())?; - if bytes.len() > PLAN_GDD_V2_MAX_BYTES { - return Err("PLANNING_SIZE_LIMIT: V2 GDD 超过 64 KiB".to_string()); - } - Ok(bytes) -} - -fn create_v2_immutable_file(root: &Path, relative: &str, bytes: &[u8]) -> Result<(), String> { - let path = v2_path(root, relative); - let parent = path - .parent() - .ok_or_else(|| "V2 文件缺少父目录".to_string())?; - ensure_game_creator_private_directory_tree(parent, "Planning V2 目录")?; - prepare_game_creator_private_path_for_read(parent, true, "Planning V2 目录")?; - match fs::OpenOptions::new() - .create_new(true) - .write(true) - .open(&path) - { - Ok(mut file) => { - // `create_new` has already made the final path visible. Every - // subsequent step must therefore be transactional from the - // caller's perspective: a failed ACL repair, partial write, or - // failed sync must not leave an empty/truncated immutable file - // that would permanently block a retry. - let result = (|| { - harden_new_game_creator_private_path(&path, false, "Planning V2 文件")?; - file.write_all(bytes) - .and_then(|_| file.sync_data()) - .map_err(|error| { - format!("写入 Planning V2 文件失败:{}: {error}", path.display()) - })?; - Ok(()) - })(); - if result.is_err() { - let _ = fs::remove_file(&path); - } - result - } - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { - prepare_game_creator_private_path_for_read(&path, false, "Planning V2 文件")?; - let existing = fs::read(&path).map_err(|read_error| { - format!( - "读取已存在 Planning V2 文件失败:{}: {read_error}", - path.display() - ) - })?; - if existing == bytes { - Ok(()) - } else { - Err(format!( - "Planning V2 不可变文件已存在且内容不同:{}", - path.display() - )) - } - } - Err(error) => Err(format!( - "创建 Planning V2 文件失败:{}: {error}", - path.display() - )), - } -} - -fn read_gdd_v2(root: &Path, version: u32) -> Result { - try_read_gdd_v2(root, version)?.ok_or_else(|| { - format!( - "读取 V2 GDD 失败:{}", - v2_path(root, &format!("gdd.v{version}.json")).display() - ) - }) -} - -fn try_read_gdd_v2(root: &Path, version: u32) -> Result, String> { - let path = v2_path(root, &format!("gdd.v{version}.json")); - if !prepare_game_creator_private_path_for_read(&path, false, "Planning V2 GDD")? { - return Ok(None); - } - let metadata = - fs::metadata(&path).map_err(|error| format!("读取 V2 GDD 元数据失败:{error}"))?; - if metadata.len() > PLAN_GDD_V2_MAX_BYTES as u64 { - return Err("PLANNING_SIZE_LIMIT: V2 GDD 超过 64 KiB".to_string()); - } - let bytes = fs::read(&path) - .map_err(|error| format!("读取 V2 GDD 失败:{}: {error}", path.display()))?; - let gdd = serde_json::from_slice::(&bytes) - .map_err(|error| format!("解析 V2 GDD 失败:{error}"))?; - let project_id = read_manifest_for_project(root)?.project_id; - validate_gdd_v2(&gdd, &project_id)?; - if gdd.version != version { - return Err("PLANNING_INVALID_GDD: GDD 文件名与 version 不一致".to_string()); - } - Ok(Some(gdd)) -} - -fn next_gdd_version_v2(session: &PlanningSessionV2) -> Result { - let next_version = session - .current_artifact_version - .map(|value| value.saturating_add(1)) - .unwrap_or(1); - u32::try_from(next_version).map_err(|_| "PLANNING_VERSION_LIMIT: GDD 版本超出范围".to_string()) -} - -fn gdd_projection_status_v2( - root: &Path, - gdd: &PlanningGddV2, -) -> Result<(&'static str, Option), String> { - Ok(match read_approval_v2(root, gdd.version)? { - Some(approval) => ( - match approval.action.as_str() { - "approve" => "approved", - "reject" => "rejected", - _ => "revision_requested", - }, - Some(approval.decision_id), - ), - None => ("ready_for_approval", None), - }) -} - -fn session_status_for_gdd_projection_v2(status: &str) -> String { - match status { - "ready_for_approval" => "awaiting_approval".to_string(), - other => other.to_string(), - } -} - -fn conversation_has_gdd_artifact_v2(messages: &[PlanningMessageV2], version: u32) -> bool { - messages.iter().any(|message| { - message.kind == "artifact" - && message.payload.get("version").and_then(Value::as_u64) == Some(u64::from(version)) - }) -} - -fn recover_gdd_client_turn_id_v2( - root: &Path, - turn_index: u64, - version: u32, -) -> Result { - let messages = read_planning_messages_v2(root)?; - if let Some(message) = messages.iter().rev().find(|message| { - message.kind == "artifact" - && message.payload.get("version").and_then(Value::as_u64) == Some(u64::from(version)) - }) { - return Ok(message.client_turn_id.clone()); - } - if let Some(message) = messages - .iter() - .rev() - .find(|message| message.role == "user" && message.turn_index == turn_index) - { - return Ok(message.client_turn_id.clone()); - } - if let Some(message) = messages.iter().rev().find(|message| message.role == "user") { - return Ok(message.client_turn_id.clone()); - } - Ok(format!("planning-v2-recover-v{version}")) -} - -fn project_committed_gdd_v2( - root: &Path, - session: &mut PlanningSessionV2, - client_turn_id: &str, - turn_index: u64, - elapsed_seconds: f64, - gdd: &PlanningGddV2, -) -> Result { - let (status, decision_id) = gdd_projection_status_v2(root, gdd)?; - update_index_v2(root, gdd, status, decision_id)?; - let markdown = render_gdd_v2_markdown(gdd, status)?; - write_plan_fast_gdd_markdown_atomic_locked(root, &markdown) - .map_err(|error| error.to_string())?; - let artifact = artifact_value_v2(gdd, status); - let messages = read_planning_messages_v2(root)?; - if !conversation_has_gdd_artifact_v2(&messages, gdd.version) { - append_planning_message_v2( - root, - &PlanningMessageV2 { - schema_version: PLANNING_MESSAGE_V2_SCHEMA_VERSION.to_string(), - message_id: format!("msg-{}", Uuid::new_v4().simple()), - client_turn_id: client_turn_id.to_string(), - turn_index, - at_utc: current_plan_timestamp_utc(), - role: "assistant".to_string(), - kind: "artifact".to_string(), - payload: artifact.clone(), - }, - )?; - } - session.current_artifact_version = Some(u64::from(gdd.version)); - session.current_question = None; - session.status = session_status_for_gdd_projection_v2(status); - if elapsed_seconds > 0.0 { - session.processing_seconds += elapsed_seconds.max(0.0); - } - session.updated_at_utc = current_plan_timestamp_utc(); - session.last_error = None; - write_planning_session_v2(root, session)?; - Ok(PlanningPolicyPersistedV2 { - session: session.clone(), - result: PlanningTurnResultV2 { - schema_version: PLANNING_TURN_RESULT_V2_SCHEMA_VERSION.to_string(), - kind: "artifact".to_string(), - payload: artifact.clone(), - }, - current_artifact: Some(artifact), - }) -} - -/// 调用方必须已持有项目写锁。`gdd.v{N}.json` 一旦创建即为提交点: -/// 只认领 session 指针的下一个连续版本,补投影,不重新生成身份。 -pub(crate) fn reconcile_committed_planning_gdd_v2( - root: &Path, -) -> Result, String> { - let Some(mut session) = read_planning_session_v2(root)? else { - return Ok(None); - }; - if matches!(session.status.as_str(), "approved" | "rejected") { - return Ok(None); - } - let next_version = next_gdd_version_v2(&session)?; - let Some(gdd) = try_read_gdd_v2(root, next_version)? else { - return Ok(None); - }; - let turn_index = session.turn_index; - let client_turn_id = recover_gdd_client_turn_id_v2(root, turn_index, gdd.version)?; - project_committed_gdd_v2(root, &mut session, &client_turn_id, turn_index, 0.0, &gdd)?; - Ok(Some(gdd)) -} - -fn read_index_v2(root: &Path) -> Result { - let path = v2_path(root, "index.json"); - let project_id = read_manifest_for_project(root)?.project_id; - prepare_game_creator_private_path_for_read(&path, false, "Planning V2 GDD index")?; - match fs::read(&path) { - Ok(bytes) => { - let index = serde_json::from_slice::(&bytes) - .map_err(|error| format!("解析 V2 GDD index 失败:{error}"))?; - if index.schema_version != PLAN_GDD_INDEX_V2_SCHEMA_VERSION - || index.project_id != project_id - { - return Err("PLANNING_INVALID_INDEX: V2 GDD index 身份无效".to_string()); - } - Ok(index) - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(PlanningGddIndexV2 { - schema_version: PLAN_GDD_INDEX_V2_SCHEMA_VERSION.to_string(), - project_id, - latest_version: None, - entries: Vec::new(), - }), - Err(error) => Err(format!( - "读取 V2 GDD index 失败:{}: {error}", - path.display() - )), - } -} - -fn write_index_v2(root: &Path, index: &PlanningGddIndexV2) -> Result<(), String> { - let bytes = - serde_json::to_vec(index).map_err(|error| format!("序列化 V2 GDD index 失败:{error}"))?; - if bytes.len() > 256 * 1024 { - return Err("PLANNING_SIZE_LIMIT: V2 GDD index 超过 256 KiB".to_string()); - } - write_game_creator_private_file( - &v2_path(root, "index.json"), - &bytes, - "Planning V2 GDD index", - ) -} - -fn update_index_v2( - root: &Path, - gdd: &PlanningGddV2, - status: &str, - decision_id: Option, -) -> Result<(), String> { - let mut index = read_index_v2(root)?; - if let Some(entry) = index - .entries - .iter_mut() - .find(|entry| entry.version == gdd.version) - { - entry.status = status.to_string(); - entry.decision_id = decision_id; - } else { - index.entries.push(PlanningGddIndexEntryV2 { - gdd_id: gdd.gdd_id.clone(), - version: gdd.version, - fingerprint: gdd.fingerprint.clone(), - status: status.to_string(), - created_at_utc: gdd.created_at_utc.clone(), - decision_id, - }); - } - index.entries.sort_by_key(|entry| entry.version); - index.latest_version = index.entries.last().map(|entry| entry.version); - write_index_v2(root, &index) -} - -fn index_status_v2(root: &Path, version: u32) -> Result { - Ok(read_index_v2(root)? - .entries - .into_iter() - .find(|entry| entry.version == version) - .map(|entry| entry.status) - .unwrap_or_else(|| "ready_for_approval".to_string())) -} - -fn markdown_escape_v2(value: &str) -> String { - value - .replace('\\', "\\\\") - .replace('`', "\\`") - .replace('*', "\\*") - .replace('_', "\\_") - .replace('|', "\\|") -} - -fn render_gdd_v2_markdown(gdd: &PlanningGddV2, status: &str) -> Result { - validate_gdd_v2(gdd, &gdd.project_id)?; - let mut output = format!( - "# {}\n\n> Fast GDD v{} · 状态:{}\n> gddId:`{}`\n> fingerprint:`{}`\n\n", - markdown_escape_v2(&gdd.game.title), - gdd.version, - markdown_escape_v2(status), - gdd.gdd_id, - gdd.fingerprint - ); - output.push_str("## 决定状态\n\n"); - for decision in &gdd.decisions { - output.push_str(&format!( - "- **{}**({},{},第 {} 轮):{}\n", - markdown_escape_v2(&decision.topic), - decision.state, - decision.answer_source, - decision.round, - markdown_escape_v2(&decision.answer_summary) - )); - } - output.push_str(&format!( - "\n## 一句话描述\n\n{}\n\n", - markdown_escape_v2(&gdd.game.one_liner) - )); - output.push_str("## 核心循环\n\n"); - for (index, step) in gdd.game.core_loop.iter().enumerate() { - output.push_str(&format!("{}. {}\n", index + 1, markdown_escape_v2(step))); - } - output.push_str("\n## MVP 系统\n\n"); - for system in &gdd.game.mvp_systems { - output.push_str(&format!( - "### {}\n\n- 最小功能:{}\n- 必要原因:{}\n- 验证方式:{}\n\n", - markdown_escape_v2(&system.system), - markdown_escape_v2(&system.minimal_function), - markdown_escape_v2(&system.why_required), - markdown_escape_v2(&system.verify_method) - )); - } - output.push_str("## 制作边界\n\n"); - for item in &gdd.game.out_of_scope { - output.push_str(&format!("- {}\n", markdown_escape_v2(item))); - } - if !gdd.prototype_validation_items.is_empty() { - output.push_str("\n## 原型验证项\n\n"); - for item in &gdd.prototype_validation_items { - output.push_str(&format!( - "### {}\n\n- 问题:{}\n- 微型原型:{}\n- 观察:{}\n- 通过标准:{}\n\n", - markdown_escape_v2(&item.id), - markdown_escape_v2(&item.question), - markdown_escape_v2(&item.micro_prototype), - markdown_escape_v2(&item.observation), - markdown_escape_v2(&item.pass_criterion) - )); - } - } - if output.len() > PLAN_FAST_GDD_MAX_BYTES { - return Err("PLANNING_SIZE_LIMIT: Fast GDD Markdown 超过大小上限".to_string()); - } - Ok(output) -} - -fn artifact_value_v2(gdd: &PlanningGddV2, status: &str) -> Value { - serde_json::json!({ - "artifactId": gdd.gdd_id, - "kind": "gdd", - "version": gdd.version, - "status": status, - "fingerprint": gdd.fingerprint, - "payload": gdd, - }) -} - -pub(crate) fn current_planning_artifact_v2(root: &Path) -> Result, String> { - let Some(session) = read_planning_session_v2(root)? else { - return Ok(None); - }; - let Some(version) = session.current_artifact_version else { - return Ok(None); - }; - let version = u32::try_from(version) - .map_err(|_| "PLANNING_INVALID_GDD: artifact version 超出范围".to_string())?; - let gdd = read_gdd_v2(root, version)?; - Ok(Some(artifact_value_v2( - &gdd, - &index_status_v2(root, version)?, - ))) -} - -pub(crate) fn persist_planning_policy_output_v2( - root: &Path, - client_turn_id: &str, - session_id: &str, - turn_index: u64, - elapsed_seconds: f64, - output: PlanningPolicyOutputV2, -) -> Result { - // 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 { - return Err("Planning V2 Session 回合身份发生变化".to_string()); - } - let project_id = read_manifest_for_project(root)?.project_id; - if session.project_id != project_id { - return Err("Planning V2 Session projectId 与当前项目不一致".to_string()); - } - match output { - PlanningPolicyOutputV2::Question(question) => { - let payload = serde_json::to_value(&question).map_err(|error| error.to_string())?; - append_planning_message_v2( - root, - &PlanningMessageV2 { - schema_version: PLANNING_MESSAGE_V2_SCHEMA_VERSION.to_string(), - message_id: format!("msg-{}", Uuid::new_v4().simple()), - client_turn_id: client_turn_id.to_string(), - turn_index, - at_utc: current_plan_timestamp_utc(), - role: "assistant".to_string(), - kind: "question".to_string(), - payload: payload.clone(), - }, - )?; - session.question_count = session.question_count.saturating_add(1); - session.current_question = Some(payload.clone()); - session.status = "awaiting_user".to_string(); - session.processing_seconds += elapsed_seconds.max(0.0); - session.updated_at_utc = current_plan_timestamp_utc(); - session.last_error = None; - write_planning_session_v2(root, &session)?; - Ok(PlanningPolicyPersistedV2 { - session, - result: PlanningTurnResultV2 { - schema_version: PLANNING_TURN_RESULT_V2_SCHEMA_VERSION.to_string(), - kind: "question".to_string(), - payload, - }, - current_artifact: None, - }) - } - PlanningPolicyOutputV2::Gdd(input) => { - let next_version = next_gdd_version_v2(&session)?; - let gdd = if let Some(existing) = try_read_gdd_v2(root, next_version)? { - existing - } else { - let gdd = build_gdd_v2(&project_id, next_version, input)?; - validate_gdd_v2(&gdd, &project_id)?; - let bytes = canonical_gdd_v2_bytes(&gdd)?; - create_v2_immutable_file(root, &format!("gdd.v{}.json", gdd.version), &bytes)?; - gdd - }; - project_committed_gdd_v2( - root, - &mut session, - client_turn_id, - turn_index, - elapsed_seconds, - &gdd, - ) - } - } -} - -fn approval_fingerprint(value: &PlanningApprovalV2) -> Result { - #[derive(Serialize)] - #[serde(rename_all = "camelCase")] - struct Fingerprint<'a> { - schema_version: &'a str, - project_id: &'a str, - session_id: &'a str, - artifact_id: &'a str, - version: u32, - fingerprint: &'a str, - decision_id: &'a str, - action: &'a str, - comment: &'a Option, - decided_at_utc: &'a str, - } - typed_serde_fingerprint( - PLAN_APPROVAL_V2_FINGERPRINT_DOMAIN, - &Fingerprint { - schema_version: &value.schema_version, - project_id: &value.project_id, - session_id: &value.session_id, - artifact_id: &value.artifact_id, - version: value.version, - fingerprint: &value.fingerprint, - decision_id: &value.decision_id, - action: &value.action, - comment: &value.comment, - decided_at_utc: &value.decided_at_utc, - }, - ) - .map_err(|error| error.to_string()) -} - -fn validate_approval_v2(value: &PlanningApprovalV2, project_id: &str) -> Result<(), String> { - if value.schema_version != PLAN_APPROVAL_V2_SCHEMA_VERSION - || value.project_id != project_id - || !(1..=PLAN_GDD_V2_MAX_VERSIONS).contains(&value.version) - || !matches!(value.action.as_str(), "approve" | "revise" | "reject") - || !is_typed_fingerprint(&value.fingerprint) - || !is_typed_fingerprint(&value.receipt_fingerprint) - { - return Err("PLANNING_INVALID_APPROVAL: V2 审批记录身份或 schema 无效".to_string()); - } - validate_uuid_prefixed(&value.artifact_id, "gdd-", "artifactId") - .map_err(|error| error.to_string())?; - validate_text(&value.decision_id, "decisionId", 1, 128).map_err(|error| error.to_string())?; - validate_timestamp(&value.decided_at_utc, "decidedAtUtc").map_err(|error| error.to_string())?; - if matches!(value.action.as_str(), "revise" | "reject") && value.comment.is_none() { - return Err("PLANNING_INVALID_APPROVAL: 修改或退回审批记录必须填写 comment".to_string()); - } - if value - .comment - .as_deref() - .is_some_and(|comment| comment.chars().count() > 2_000) - { - return Err("PLANNING_INVALID_APPROVAL: comment 不能超过 2000 个字符".to_string()); - } - let expected = approval_fingerprint(value)?; - if expected != value.receipt_fingerprint { - return Err("PLANNING_INVALID_APPROVAL: receiptFingerprint 不匹配".to_string()); - } - Ok(()) -} - -fn read_approval_v2(root: &Path, version: u32) -> Result, String> { - let path = v2_path(root, &format!("approvals/v{version}.json")); - prepare_game_creator_private_path_for_read(&path, false, "Planning V2 审批记录")?; - match fs::read(&path) { - Ok(bytes) => { - let approval = serde_json::from_slice::(&bytes) - .map_err(|error| format!("解析 V2 审批记录失败:{error}"))?; - let project_id = read_manifest_for_project(root)?.project_id; - validate_approval_v2(&approval, &project_id)?; - Ok(Some(approval)) - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(error) => Err(format!("读取 V2 审批记录失败:{}: {error}", path.display())), - } -} - -fn write_approval_v2(root: &Path, approval: &PlanningApprovalV2) -> Result<(), String> { - let bytes = - serde_json::to_vec(approval).map_err(|error| format!("序列化 V2 审批记录失败:{error}"))?; - create_v2_immutable_file( - root, - &format!("approvals/v{}.json", approval.version), - &bytes, - ) -} - -pub(crate) fn decide_planning_artifact_v2_at( - root: &Path, - input: DecidePlanningArtifactV2Input, -) -> Result { - // 审批按钮是一次性意图。修订后续跑和 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; - if session.project_id != project_id { - return Err("Planning V2 Session projectId 与当前项目不一致".to_string()); - } - if session.session_id != input.session_id { - return Err("Planning V2 Session ID 不匹配".to_string()); - } - if !matches!(input.action.as_str(), "approve" | "revise" | "reject") { - return Err("PLANNING_INVALID_APPROVAL: action 必须是 approve/revise/reject".to_string()); - } - let comment = input - .comment - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()); - if input.decision_id.trim().is_empty() || input.decision_id.chars().count() > 128 { - return Err("PLANNING_INVALID_APPROVAL: decisionId 不能为空且长度不能超过 128".to_string()); - } - if comment - .as_deref() - .is_some_and(|value| value.chars().count() > 2_000) - { - return Err("PLANNING_INVALID_APPROVAL: comment 不能超过 2000 个字符".to_string()); - } - if matches!(input.action.as_str(), "revise" | "reject") && comment.is_none() { - return Err("PLANNING_INVALID_APPROVAL: 修改或退回必须填写 comment".to_string()); - } - let gdd = read_gdd_v2(root, input.version)?; - if gdd.gdd_id != input.artifact_id - || gdd.fingerprint != input.fingerprint - || session.current_artifact_version != Some(u64::from(input.version)) - { - return Err("PLANNING_STALE_APPROVAL: 审批引用不是当前最新 GDD".to_string()); - } - if let Some(existing) = read_approval_v2(root, input.version)? { - if existing.session_id == session.session_id - && existing.action == input.action - && existing.comment == comment - { - let status = match existing.action.as_str() { - "approve" => "approved", - "reject" => "rejected", - _ => "revision_requested", - }; - update_index_v2(root, &gdd, status, Some(existing.decision_id.clone()))?; - let markdown = render_gdd_v2_markdown(&gdd, status)?; - write_plan_fast_gdd_markdown_atomic_locked(root, &markdown) - .map_err(|error| error.to_string())?; - if session.status != status { - session.status = status.to_string(); - if status == "revision_requested" { - session.revision_count = session.revision_count.saturating_add(1); - } - session.updated_at_utc = current_plan_timestamp_utc(); - write_planning_session_v2(root, &session)?; - } - return Ok(PlanningApprovalCommandResultV2 { - approval: existing, - session, - current_artifact: Some(artifact_value_v2( - &gdd, - &index_status_v2(root, input.version)?, - )), - replayed: true, - }); - } - return Err("PLANNING_APPROVAL_CONFLICT: 当前 GDD 已存在不同审批决定".to_string()); - } - let mut approval = PlanningApprovalV2 { - schema_version: PLAN_APPROVAL_V2_SCHEMA_VERSION.to_string(), - project_id: project_id.clone(), - session_id: session.session_id.clone(), - artifact_id: gdd.gdd_id.clone(), - version: gdd.version, - fingerprint: gdd.fingerprint.clone(), - decision_id: input.decision_id.clone(), - action: input.action.clone(), - comment: comment.clone(), - decided_at_utc: current_plan_timestamp_utc(), - receipt_fingerprint: String::new(), - }; - approval.receipt_fingerprint = approval_fingerprint(&approval)?; - write_approval_v2(root, &approval)?; - if input.action != "revise" { - append_planning_message_v2( - root, - &PlanningMessageV2 { - schema_version: PLANNING_MESSAGE_V2_SCHEMA_VERSION.to_string(), - message_id: format!("msg-{}", Uuid::new_v4().simple()), - client_turn_id: input.decision_id.clone(), - turn_index: session.turn_index, - at_utc: approval.decided_at_utc.clone(), - role: "user".to_string(), - kind: "text".to_string(), - payload: serde_json::json!({"text": format!("审批:{} {}", input.action, comment.as_deref().unwrap_or_default()), "approvalAction": input.action, "artifactId": gdd.gdd_id, "version": gdd.version}), - }, - )?; - } - update_index_v2( - root, - &gdd, - match input.action.as_str() { - "approve" => "approved", - "reject" => "rejected", - _ => "revision_requested", - }, - Some(input.decision_id.clone()), - )?; - let status = match input.action.as_str() { - "approve" => "approved", - "reject" => "rejected", - _ => "revision_requested", - }; - let markdown = render_gdd_v2_markdown(&gdd, status)?; - write_plan_fast_gdd_markdown_atomic_locked(root, &markdown) - .map_err(|error| error.to_string())?; - session.status = status.to_string(); - session.current_question = None; - if input.action == "revise" { - session.revision_count = session.revision_count.saturating_add(1); - } - session.updated_at_utc = current_plan_timestamp_utc(); - write_planning_session_v2(root, &session)?; - Ok(PlanningApprovalCommandResultV2 { - approval, - session, - current_artifact: Some(artifact_value_v2(&gdd, status)), - replayed: false, - }) -} - -#[tauri::command] -pub(crate) fn decide_planning_artifact_v2( - project_path: String, - session_id: String, - artifact_id: String, - version: u32, - fingerprint: String, - decision_id: String, - action: String, - comment: Option, -) -> Result { - let root = PathBuf::from(project_path.trim()); - enforce_project_permission_policy(&root, "conversation.read")?; - enforce_project_permission_policy(&root, "conversation.write")?; - decide_planning_artifact_v2_at( - &root, - DecidePlanningArtifactV2Input { - session_id, - artifact_id, - version, - fingerprint, - decision_id, - action, - comment, - }, - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn missing_answer_source_is_normalized_without_blocking() { - assert_eq!( - normalize_answer_source("confirmed", None).unwrap(), - "user_freeform" - ); - assert_eq!( - normalize_answer_source("assumption_pending", None).unwrap(), - "agent_inferred" - ); - assert_eq!( - normalize_answer_source("assumption_pending", Some("user_option".to_string())).unwrap(), - "agent_inferred" - ); - assert_eq!( - normalize_answer_source("prototype_pending", None).unwrap(), - "user_option" - ); - assert_eq!( - normalize_v2_decision_state("default_pending").is_err(), - true - ); - } - - #[test] - fn question_validation_allows_two_to_four_options() { - let question = PlanningQuestionV2 { - id: "core_loop".to_string(), - header: "当前要决定:核心循环".to_string(), - question: "玩家主要做什么?".to_string(), - options: vec![ - PlanningQuestionOptionV2 { - label: "方案 A".to_string(), - description: "先设计再验证".to_string(), - }, - PlanningQuestionOptionV2 { - label: "方案 B".to_string(), - description: "先战斗再调整".to_string(), - }, - ], - }; - assert!(validate_question_v2(&question).is_ok()); - } - - fn sample_gdd_value() -> Value { - serde_json::json!({ - "game": { - "title": "萤火守夜者", - "genre": {"primary": "轻策略", "fusion": null}, - "artStyle": { - "visualType": "手绘平面", - "keywords": ["暖色", "剪影", "纸感"], - "moodAndColor": "夜色中的暖黄灯火", - "mvpArtBoundary": "只做可复用占位素材" - }, - "oneLiner": "玩家在短局守夜旅程中分配有限灯火、判断风险并选择路线,守住营地后寻找下一处安全落脚点再继续前进", - "pillars": [ - {"name": "取舍", "playerFeel": "每次选择都有代价", "mechanism": "有限灯火在路线与营地之间分配", "decisionState": "confirmed"}, - {"name": "重玩", "playerFeel": "想再试一次更优路线", "mechanism": "不同路线组合产生不同风险", "decisionState": "confirmed"} - ], - "coreLoop": ["观察地图", "分配灯火", "选择路线", "处理事件"], - "targetUsers": {"coreUsers": "喜欢短局策略的玩家", "preferences": "偏好清晰反馈", "sessionLength": "10至20分钟", "referenceGames": []}, - "mvpSystems": [ - {"system": "地图", "minimalFunction": "展示当前营地与路线", "whyRequired": "承载空间选择", "verifyMethod": "玩家能走完一局", "decisionState": "confirmed"}, - {"system": "灯火", "minimalFunction": "在安全和探索间分配", "whyRequired": "承载核心取舍", "verifyMethod": "两种策略结果可区分", "decisionState": "confirmed"}, - {"system": "事件", "minimalFunction": "路线途中触发选择", "whyRequired": "提供短局变化", "verifyMethod": "重玩时结果不同", "decisionState": "confirmed"} - ], - "outOfScope": ["多人联机"], - "creatorTips": {"doFirst": "先做一张可走完的地图", "deferForNow": "暂缓复杂成长线", "howToVerify": "观察玩家是否能说出选择后果", "expandWhen": "连续三局都能理解后再扩展"} - }, - "decisions": [{"topic": "初始需求", "state": "confirmed", "answerSummary": "做一个短局守夜策略游戏"}], - "prototypeValidationItems": [] - }) - } - - fn tool_turn_result(name: &str, arguments: Value) -> PlanningTurnResultV2 { - PlanningTurnResultV2 { - schema_version: PLANNING_TURN_RESULT_V2_SCHEMA_VERSION.to_string(), - kind: "assistant_text".to_string(), - payload: serde_json::json!({ - "text": "", - "toolCalls": [{ - "id": "call-1", - "name": name, - "arguments": arguments - }] - }), - } - } - - #[test] - fn parses_v2_gdd_from_submit_tool_without_schema_version() { - let result = tool_turn_result( - PLAN_SUBMIT_GDD_TOOL_NAME, - serde_json::json!({"gdd": sample_gdd_value()}), - ); - let parsed = parse_planning_policy_output_v2(&result).expect("parse v2 gdd"); - let PlanningPolicyOutputV2::Gdd(gdd) = parsed else { - panic!("expected gdd"); - }; - assert!(gdd.schema_version.is_none()); - validate_planning_policy_output_v2(&PlanningPolicyOutputV2::Gdd(gdd)).expect("valid gdd"); - let text_json = PlanningTurnResultV2 { - schema_version: PLANNING_TURN_RESULT_V2_SCHEMA_VERSION.to_string(), - kind: "assistant_text".to_string(), - payload: serde_json::json!({ - "text": "{\"kind\":\"gdd\",\"gdd\":{\"schemaVersion\":\"plan-gdd.v2\"}}" - }), - }; - assert!(parse_planning_policy_output_v2(&text_json).is_err()); - } - - #[test] - fn parses_v2_question_from_ask_tool() { - let result = tool_turn_result( - PLAN_ASK_QUESTION_TOOL_NAME, - serde_json::json!({ - "question": { - "id": "core_loop", - "header": "当前要决定:核心循环", - "question": "玩家主要做什么?", - "options": [ - {"label": "方案 A", "description": "先设计再验证"}, - {"label": "方案 B", "description": "先战斗再调整"} - ] - } - }), - ); - let parsed = parse_planning_policy_output_v2(&result).expect("parse question"); - assert!(matches!(parsed, PlanningPolicyOutputV2::Question(_))); - } - - #[test] - fn planning_v2_tool_schema_describes_nested_gdd_without_schema_version() { - let tools = planning_v2_function_tools(); - assert_eq!(tools.len(), 2); - assert_eq!(tools[0].name, PLAN_ASK_QUESTION_TOOL_NAME); - assert_eq!(tools[1].name, PLAN_SUBMIT_GDD_TOOL_NAME); - let gdd_properties = tools[1].parameters["properties"]["gdd"]["properties"] - .as_object() - .expect("gdd properties"); - assert!(gdd_properties.contains_key("game")); - assert!(gdd_properties.contains_key("decisions")); - assert!(!gdd_properties.contains_key("schemaVersion")); - let decision_properties = gdd_properties["decisions"]["items"]["properties"] - .as_object() - .expect("decision properties"); - assert!(!decision_properties.contains_key("id")); - assert!(!decision_properties.contains_key("round")); - assert!(!gdd_properties["decisions"]["items"]["required"] - .as_array() - .expect("decision required") - .iter() - .any(|value| value == "id")); - let prototype_properties = gdd_properties["prototypeValidationItems"]["items"] - ["properties"] - .as_object() - .expect("prototype properties"); - assert!(!prototype_properties.contains_key("id")); - assert!(gdd_properties["game"]["properties"] - .as_object() - .expect("game properties") - .contains_key("title")); - } - - #[test] - fn runtime_assigns_decision_and_prototype_ids() { - let mut value = sample_gdd_value(); - value["decisions"] = serde_json::json!([ - {"topic": "初始需求", "state": "confirmed", "answerSummary": "做一个短局守夜策略游戏"}, - {"topic": "核心回路", "state": "prototype_pending", "answerSummary": "验证核心回路"} - ]); - value["prototypeValidationItems"] = serde_json::json!([ - {"question": "玩家是否理解核心回路?", "microPrototype": "做一个最小交互原型", "observation": "观察玩家行为", "passCriterion": "多数玩家完成目标"} - ]); - let input: PlanningGddInputV2 = serde_json::from_value(value).expect("input"); - let gdd = build_gdd_v2("project-v2", 1, input).expect("build gdd"); - assert_eq!( - gdd.decisions - .iter() - .map(|decision| decision.id.as_str()) - .collect::>(), - vec!["initial-request", "decision-1"] - ); - assert_eq!(gdd.prototype_validation_items[0].id, "decision-1"); - } - - fn sample_gdd_input() -> PlanningGddInputV2 { - serde_json::from_value(sample_gdd_value()).expect("sample gdd input") - } - - fn v2_persist_fixture() -> (tempfile::TempDir, PathBuf, PlanningSessionV2) { - let directory = tempfile::tempdir().expect("create v2 persist fixture"); - let root = directory.path().to_path_buf(); - crate::init_local_game_project_at(&root, "project-v2-persist", "V2 持久化测试") - .expect("init project"); - let project_id = crate::read_manifest_for_project(&root) - .expect("read manifest") - .project_id; - let now = current_plan_timestamp_utc(); - let session = PlanningSessionV2 { - schema_version: PLANNING_SESSION_V2_SCHEMA_VERSION.to_string(), - engine: PLANNING_SESSION_V2_ENGINE.to_string(), - session_id: "ps-test-persist".to_string(), - project_id, - mode: "gdd".to_string(), - status: "planning".to_string(), - turn_index: 1, - question_count: 0, - question_limit: Some(8), - revision_count: 0, - current_artifact_version: None, - current_question: None, - capabilities: PlanningCapabilitySnapshotV2::default(), - processing_seconds: 0.0, - created_at_utc: now.clone(), - updated_at_utc: now, - last_error: None, - }; - write_planning_session_v2(&root, &session).expect("write session"); - append_planning_message_v2( - &root, - &PlanningMessageV2 { - schema_version: PLANNING_MESSAGE_V2_SCHEMA_VERSION.to_string(), - message_id: "msg-user-1".to_string(), - client_turn_id: "turn-1".to_string(), - turn_index: 1, - at_utc: current_plan_timestamp_utc(), - role: "user".to_string(), - kind: "text".to_string(), - payload: serde_json::json!({"text": "做一个短局守夜策略游戏"}), - }, - ) - .expect("write user message"); - (directory, root, session) - } - - fn rewind_session_keep_gdd_file(root: &Path, session: &PlanningSessionV2) { - let mut session = session.clone(); - session.current_artifact_version = None; - session.status = "provider_failed".to_string(); - session.last_error = Some(PlanningErrorV2 { - code: "PLANNING_PERSIST_FAILED".to_string(), - summary: "投影失败".to_string(), - }); - write_planning_session_v2(root, &session).expect("rewind session"); - let _ = fs::remove_file(v2_path(root, "index.json")); - let _ = fs::remove_file(root.join("game/fast_gdd.md")); - let users = read_planning_messages_v2(root) - .expect("read conversation") - .into_iter() - .filter(|message| message.role == "user") - .collect::>(); - let mut content = String::new(); - for message in users { - content.push_str(&serde_json::to_string(&message).expect("serialize user message")); - content.push('\n'); - } - crate::write_game_creator_private_file( - &root.join(PLANNING_SESSION_V2_CONVERSATION_PATH), - content.as_bytes(), - "Planning V2 对话记录", - ) - .expect("rewrite conversation"); - } - - #[test] - fn persist_retries_adopt_existing_gdd_instead_of_conflicting_identity() { - let (_dir, root, session) = v2_persist_fixture(); - let first = persist_planning_policy_output_v2( - &root, - "turn-1", - &session.session_id, - session.turn_index, - 1.0, - PlanningPolicyOutputV2::Gdd(sample_gdd_input()), - ) - .expect("first persist"); - let first_id = first - .current_artifact - .as_ref() - .and_then(|value| value.get("artifactId")) - .and_then(Value::as_str) - .expect("first gdd id") - .to_string(); - assert_eq!(first.session.current_artifact_version, Some(1)); - rewind_session_keep_gdd_file(&root, &session); - let mut changed = sample_gdd_input(); - changed.game.title = "完全不同的标题".to_string(); - let retry = persist_planning_policy_output_v2( - &root, - "turn-1", - &session.session_id, - session.turn_index, - 1.0, - PlanningPolicyOutputV2::Gdd(changed), - ) - .expect("retry persist"); - assert_eq!(retry.session.current_artifact_version, Some(1)); - assert_eq!(retry.session.status, "awaiting_approval"); - assert_eq!( - retry - .current_artifact - .as_ref() - .and_then(|value| value.get("artifactId")) - .and_then(Value::as_str), - Some(first_id.as_str()) - ); - assert!(v2_path(&root, "gdd.v1.json").is_file()); - assert!(!v2_path(&root, "gdd.v2.json").exists()); - let gdd = read_gdd_v2(&root, 1).expect("read adopted gdd"); - assert_eq!(gdd.gdd_id, first_id); - assert_eq!(gdd.game.title, "萤火守夜者"); - let artifacts = read_planning_messages_v2(&root) - .expect("read conversation") - .into_iter() - .filter(|message| message.kind == "artifact") - .count(); - assert_eq!(artifacts, 1); - } - - #[test] - fn hydrate_adopts_orphan_gdd_after_projection_failure() { - let (_dir, root, session) = v2_persist_fixture(); - persist_planning_policy_output_v2( - &root, - "turn-1", - &session.session_id, - session.turn_index, - 1.0, - PlanningPolicyOutputV2::Gdd(sample_gdd_input()), - ) - .expect("first persist"); - rewind_session_keep_gdd_file(&root, &session); - let hydrated = hydrate_planning_session_v2_sync( - root.to_string_lossy().to_string(), - Some(session.session_id.clone()), - ) - .expect("hydrate") - .expect("session"); - assert_eq!(hydrated.session.current_artifact_version, Some(1)); - assert_eq!(hydrated.session.status, "awaiting_approval"); - assert!(hydrated.current_artifact.is_some()); - assert!(hydrated - .conversation - .as_ref() - .expect("conversation") - .iter() - .any(|message| message.kind == "artifact")); - } - - #[test] - fn successful_gdd_persist_still_allocates_next_version() { - let (_dir, root, session) = v2_persist_fixture(); - persist_planning_policy_output_v2( - &root, - "turn-1", - &session.session_id, - session.turn_index, - 1.0, - PlanningPolicyOutputV2::Gdd(sample_gdd_input()), - ) - .expect("persist v1"); - let mut next = sample_gdd_input(); - next.game.title = "第二版守夜者".to_string(); - let second = persist_planning_policy_output_v2( - &root, - "turn-2", - &session.session_id, - session.turn_index, - 1.0, - PlanningPolicyOutputV2::Gdd(next), - ) - .expect("persist v2"); - assert_eq!(second.session.current_artifact_version, Some(2)); - assert!(v2_path(&root, "gdd.v1.json").is_file()); - assert!(v2_path(&root, "gdd.v2.json").is_file()); - assert_eq!( - read_gdd_v2(&root, 2).expect("read v2").game.title, - "第二版守夜者" - ); - } - - 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 deleted file mode 100644 index 0d5aa7239..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_session_v2.rs +++ /dev/null @@ -1,1756 +0,0 @@ -use super::*; - -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::collections::BTreeSet; -use std::fs; -use std::io::{BufRead, BufReader}; -use std::path::{Path, PathBuf}; -use std::sync::{Mutex, OnceLock}; -use std::time::Instant; -use uuid::Uuid; - -pub(crate) const PLANNING_SESSION_V2_SCHEMA_VERSION: &str = "planning-session.v2"; -pub(crate) const PLANNING_MESSAGE_V2_SCHEMA_VERSION: &str = "planning-message.v2"; -pub(crate) const PLANNING_TURN_RESULT_V2_SCHEMA_VERSION: &str = "planning-turn-result.v2"; -pub(crate) const PLANNING_SESSION_V2_ENGINE: &str = "planning-session-v2"; -pub(crate) const PLANNING_SESSION_V2_SESSION_PATH: &str = ".agent/planning-v2/session.json"; -pub(crate) const PLANNING_SESSION_V2_CONVERSATION_PATH: &str = - ".agent/planning-v2/conversation.jsonl"; -const PLANNING_SESSION_V2_DEBUG_ROOT: &str = ".agent/planning-v2/debug"; -const PLANNING_SESSION_V2_DEBUG_SCHEMA_VERSION: &str = "planning-debug.v1"; -const PLANNING_SESSION_V2_MAX_CONTEXT_CHARS: usize = 1_000_000; -const PLANNING_SESSION_V2_MAX_TEXT_CHARS: usize = 64 * 1024; -const PLANNING_SESSION_V2_MAX_CONVERSATION_BYTES: u64 = 4 * 1024 * 1024; - -static ACTIVE_PLANNING_V2_PROJECTS: OnceLock>> = OnceLock::new(); - -fn active_planning_v2_projects() -> &'static Mutex> { - ACTIVE_PLANNING_V2_PROJECTS.get_or_init(|| Mutex::new(BTreeSet::new())) -} - -struct PlanningV2ActiveGuard { - project_id: String, -} - -impl Drop for PlanningV2ActiveGuard { - fn drop(&mut self) { - if let Ok(mut active) = active_planning_v2_projects().lock() { - active.remove(&self.project_id); - } - } -} - -fn try_acquire_planning_v2_active(project_id: &str) -> Result { - let mut active = active_planning_v2_projects() - .lock() - .map_err(|_| "Planning V2 活跃回合锁损坏".to_string())?; - if !active.insert(project_id.to_string()) { - return Err("Planning V2 当前已有回合执行中".to_string()); - } - Ok(PlanningV2ActiveGuard { - project_id: project_id.to_string(), - }) -} - -fn planning_v2_is_active(project_id: &str) -> bool { - active_planning_v2_projects() - .lock() - .map(|active| active.contains(project_id)) - .unwrap_or(false) -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct PlanningCapabilitySnapshotV2 { - pub tools: Vec, - pub skills: Vec, -} - -impl Default for PlanningCapabilitySnapshotV2 { - fn default() -> Self { - Self { - tools: Vec::new(), - skills: Vec::new(), - } - } -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct PlanningErrorV2 { - pub code: String, - pub summary: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct PlanningSessionV2 { - pub schema_version: String, - pub engine: String, - pub session_id: String, - pub project_id: String, - pub mode: String, - pub status: String, - pub turn_index: u64, - pub question_count: u64, - pub question_limit: Option, - pub revision_count: u64, - pub current_artifact_version: Option, - pub current_question: Option, - pub capabilities: PlanningCapabilitySnapshotV2, - pub processing_seconds: f64, - pub created_at_utc: String, - pub updated_at_utc: String, - pub last_error: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct PlanningMessageV2 { - pub schema_version: String, - pub message_id: String, - pub client_turn_id: String, - pub turn_index: u64, - pub at_utc: String, - pub role: String, - pub kind: String, - pub payload: Value, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct PlanningTurnResultV2 { - pub schema_version: String, - pub kind: String, - pub payload: Value, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct PlanningSessionCommandResultV2 { - pub session: PlanningSessionV2, - pub result: Option, - pub current_artifact: Option, - pub replayed: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub conversation: Option>, -} - -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct PlanningSessionStreamEventV2 { - pub session_id: String, - pub client_turn_id: String, - pub status: String, - pub delta_text: String, - pub accumulated_text: String, - pub finish_reason: Option, - pub result: Option, - pub error: Option, -} - -#[derive(Clone, Debug)] -struct PlanningTurnStartV2 { - session: PlanningSessionV2, - context_messages: Vec, - replay: Option, -} - -fn planning_session_path(root: &Path) -> PathBuf { - root.join(PLANNING_SESSION_V2_SESSION_PATH) -} - -fn planning_conversation_path(root: &Path) -> PathBuf { - root.join(PLANNING_SESSION_V2_CONVERSATION_PATH) -} - -fn planning_debug_call_dir(root: &Path, debug_call_id: &str) -> PathBuf { - root.join(PLANNING_SESSION_V2_DEBUG_ROOT) - .join(debug_call_id) -} - -fn write_planning_debug_json( - root: &Path, - debug_call_id: &str, - relative: &str, - value: &Value, -) -> Option { - let path = planning_debug_call_dir(root, debug_call_id).join(relative); - let mut bytes = serde_json::to_vec_pretty(value).ok()?; - bytes.push(b'\n'); - write_game_creator_private_file(&path, &bytes, "Planning V2 Provider 诊断") - .ok() - .map(|_| format!("{PLANNING_SESSION_V2_DEBUG_ROOT}/{debug_call_id}/{relative}")) -} - -fn append_planning_debug_event(root: &Path, debug_call_id: &str, event: Value) { - let path = planning_debug_call_dir(root, debug_call_id).join("events.jsonl"); - let Ok(line) = serde_json::to_string(&event) else { - return; - }; - let mut content = match fs::read_to_string(&path) { - Ok(content) => content, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), - Err(_) => return, - }; - if !content.is_empty() && !content.ends_with('\n') { - content.push('\n'); - } - content.push_str(&line); - content.push('\n'); - let _ = - write_game_creator_private_file(&path, content.as_bytes(), "Planning V2 Provider 诊断事件"); -} - -fn persist_planning_debug_request_v2( - root: &Path, - debug_call_id: &str, - session: &PlanningSessionV2, - client_turn_id: &str, - provider_attempt: u8, - stream: bool, - request: &platform_llm::LlmRunRequest, -) { - let snapshot = serde_json::json!({ - "schemaVersion": PLANNING_SESSION_V2_DEBUG_SCHEMA_VERSION, - "kind": "provider_request", - "debugCallId": debug_call_id, - "sessionId": &session.session_id, - "projectId": &session.project_id, - "clientTurnId": client_turn_id, - "turnIndex": session.turn_index, - "providerAttempt": provider_attempt, - "model": request.model.as_deref(), - "apiKind": request.api_kind, - "stream": stream, - "maxOutputTokens": request.max_output_tokens, - "requestTimeoutMs": request.request_timeout_ms, - "reasoningEffort": format!("{:?}", request.response_reasoning_effort), - "textVerbosity": format!("{:?}", request.response_text_verbosity), - "toolChoice": request.tool_choice, - "messages": &request.messages, - "tools": &request.function_tools, - }); - let file = format!("requests/attempt-{provider_attempt}.json"); - let path = write_planning_debug_json(root, debug_call_id, &file, &snapshot); - append_planning_debug_event( - root, - debug_call_id, - serde_json::json!({ - "schemaVersion": PLANNING_SESSION_V2_DEBUG_SCHEMA_VERSION, - "eventType": "provider_attempt_started", - "debugCallId": debug_call_id, - "sessionId": &session.session_id, - "projectId": &session.project_id, - "clientTurnId": client_turn_id, - "turnIndex": session.turn_index, - "providerAttempt": provider_attempt, - "requestFile": path, - "atUtc": current_plan_timestamp_utc(), - }), - ); -} - -fn persist_planning_debug_response_v2( - root: &Path, - debug_call_id: &str, - session: &PlanningSessionV2, - client_turn_id: &str, - provider_attempt: u8, - response: Option<&platform_llm::LlmRunResponse>, - error: Option<&str>, -) { - let response_value = response.map(|response| { - serde_json::json!({ - "provider": response.provider, - "model": &response.model, - "responseId": &response.response_id, - "finishReason": &response.finish_reason, - "usage": &response.usage, - "text": &response.text, - "toolCalls": &response.tool_calls, - }) - }); - let snapshot = serde_json::json!({ - "schemaVersion": PLANNING_SESSION_V2_DEBUG_SCHEMA_VERSION, - "kind": "provider_response", - "debugCallId": debug_call_id, - "sessionId": &session.session_id, - "projectId": &session.project_id, - "clientTurnId": client_turn_id, - "turnIndex": session.turn_index, - "providerAttempt": provider_attempt, - "response": response_value, - "error": error, - "atUtc": current_plan_timestamp_utc(), - }); - let file = format!("responses/attempt-{provider_attempt}.json"); - let path = write_planning_debug_json(root, debug_call_id, &file, &snapshot); - append_planning_debug_event( - root, - debug_call_id, - serde_json::json!({ - "schemaVersion": PLANNING_SESSION_V2_DEBUG_SCHEMA_VERSION, - "eventType": "provider_attempt_finished", - "debugCallId": debug_call_id, - "sessionId": &session.session_id, - "projectId": &session.project_id, - "clientTurnId": client_turn_id, - "turnIndex": session.turn_index, - "providerAttempt": provider_attempt, - "responseFile": path, - "error": error, - "atUtc": current_plan_timestamp_utc(), - }), - ); -} - -fn persist_planning_debug_classification_v2( - root: &Path, - debug_call_id: &str, - session: &PlanningSessionV2, - client_turn_id: &str, - provider_attempt: u8, - parsed_type: Option<&str>, - accepted: bool, - retry_scheduled: bool, - error: Option<&str>, -) { - append_planning_debug_event( - root, - debug_call_id, - serde_json::json!({ - "schemaVersion": PLANNING_SESSION_V2_DEBUG_SCHEMA_VERSION, - "eventType": "provider_output_classified", - "debugCallId": debug_call_id, - "sessionId": &session.session_id, - "projectId": &session.project_id, - "clientTurnId": client_turn_id, - "turnIndex": session.turn_index, - "providerAttempt": provider_attempt, - "parsedType": parsed_type, - "accepted": accepted, - "retryScheduled": retry_scheduled, - "error": error, - "atUtc": current_plan_timestamp_utc(), - }), - ); -} - -fn validate_client_turn_id(value: &str) -> Result { - let value = value.trim(); - if value.is_empty() || value.chars().count() > 128 || value.contains(['\r', '\n', '\0']) { - return Err("clientTurnId 不能为空且长度不能超过 128 个字符".to_string()); - } - Ok(value.to_string()) -} - -fn validate_prompt(value: &str) -> Result { - let value = value.trim(); - if value.is_empty() { - return Err("策划输入不能为空".to_string()); - } - if value.chars().count() > PLANNING_SESSION_V2_MAX_TEXT_CHARS { - return Err("策划输入过长".to_string()); - } - Ok(value.to_string()) -} - -fn normalize_planning_input(value: Value) -> Result { - match value { - Value::String(text) => validate_prompt(&text), - Value::Object(object) => { - if let Some(text) = object.get("text").and_then(Value::as_str) { - return validate_prompt(text); - } - if let Some(input) = object.get("input").and_then(Value::as_str) { - return validate_prompt(input); - } - if let Some(label) = object.get("optionLabel").and_then(Value::as_str) { - return validate_prompt(&format!("按选项:{label}")); - } - if let Some(index) = object.get("optionIndex").and_then(Value::as_u64) { - return validate_prompt(&format!("按第 {} 个选项做", index.saturating_add(1))); - } - Err("Planning V2 input 缺少 text、input 或 optionLabel".to_string()) - } - _ => Err("Planning V2 input 必须是文本或对象".to_string()), - } -} - -fn safe_error(code: &str, detail: impl AsRef) -> PlanningErrorV2 { - PlanningErrorV2 { - code: code.to_string(), - summary: sanitize_diagnostic_message(detail.as_ref(), None), - } -} - -pub(crate) fn read_planning_session_v2(root: &Path) -> Result, String> { - let path = planning_session_path(root); - if !prepare_game_creator_private_path_for_read(&path, false, "Planning V2 Session")? { - return Ok(None); - } - let metadata = fs::metadata(&path) - .map_err(|error| format!("读取 Planning V2 Session 元数据失败:{error}"))?; - if metadata.len() > 64 * 1024 { - return Err("PLANNING_SESSION_INVALID: Planning V2 Session 超过大小上限".to_string()); - } - let content = fs::read_to_string(&path) - .map_err(|error| format!("读取 Planning V2 Session 失败:{}: {error}", path.display()))?; - let session = serde_json::from_str::(&content) - .map_err(|error| format!("解析 Planning V2 Session 失败:{}: {error}", path.display()))?; - validate_planning_session_v2(&session)?; - Ok(Some(session)) -} - -fn validate_planning_session_v2(session: &PlanningSessionV2) -> Result<(), String> { - if session.schema_version != PLANNING_SESSION_V2_SCHEMA_VERSION - || session.engine != PLANNING_SESSION_V2_ENGINE - || session.session_id.trim().is_empty() - || session.project_id.trim().is_empty() - || !matches!( - session.status.as_str(), - "idle" - | "planning" - | "awaiting_user" - | "awaiting_approval" - | "revision_requested" - | "approved" - | "rejected" - | "provider_failed" - ) - || session - .capabilities - .tools - .iter() - .any(|value| value.trim().is_empty()) - || session - .capabilities - .skills - .iter() - .any(|value| value.trim().is_empty()) - { - return Err("PLANNING_SESSION_INVALID: Planning V2 Session 字段无效".to_string()); - } - Ok(()) -} - -pub(crate) fn write_planning_session_v2( - root: &Path, - session: &PlanningSessionV2, -) -> Result<(), String> { - let content = serde_json::to_vec_pretty(session) - .map_err(|error| format!("序列化 Planning V2 Session 失败:{error}"))?; - write_game_creator_private_file( - &planning_session_path(root), - format!("{}\n", String::from_utf8_lossy(&content)).as_bytes(), - "Planning V2 Session", - ) -} - -pub(crate) fn append_planning_message_v2( - root: &Path, - message: &PlanningMessageV2, -) -> Result<(), String> { - let line = serde_json::to_string(message) - .map_err(|error| format!("序列化 Planning V2 消息失败:{error}"))?; - let path = planning_conversation_path(root); - if let Some(parent) = path.parent() { - ensure_game_creator_private_directory_tree(parent, "Planning V2 对话目录")?; - prepare_game_creator_private_path_for_read(parent, true, "Planning V2 对话目录")?; - } - let mut content = match fs::read_to_string(&path) { - Ok(content) => content, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), - Err(error) => { - return Err(format!( - "读取 Planning V2 对话记录失败:{}: {error}", - path.display() - )) - } - }; - if !content.is_empty() && !content.ends_with('\n') { - content.push('\n'); - } - content.push_str(&line); - content.push('\n'); - if content.len() > PLANNING_SESSION_V2_MAX_CONTEXT_CHARS.saturating_mul(4) { - return Err("Planning V2 对话记录超过有界大小".to_string()); - } - write_game_creator_private_file(&path, content.as_bytes(), "Planning V2 对话记录") -} - -pub(crate) fn read_planning_messages_v2(root: &Path) -> Result, String> { - let path = planning_conversation_path(root); - if !prepare_game_creator_private_path_for_read(&path, false, "Planning V2 对话记录")? { - return Ok(Vec::new()); - } - let metadata = fs::metadata(&path) - .map_err(|error| format!("读取 Planning V2 对话记录元数据失败:{error}"))?; - if metadata.len() > PLANNING_SESSION_V2_MAX_CONVERSATION_BYTES { - return Err("PLANNING_CONTEXT_OVERFLOW: Planning V2 对话记录超过有界大小".to_string()); - } - let file = fs::File::open(&path) - .map_err(|error| format!("读取 Planning V2 对话记录失败:{}: {error}", path.display()))?; - let mut messages = Vec::new(); - for line in BufReader::new(file).lines() { - let line = line.map_err(|error| format!("读取 Planning V2 消息失败:{error}"))?; - if line.trim().is_empty() { - continue; - } - let message = serde_json::from_str::(&line) - .map_err(|error| format!("解析 Planning V2 消息失败:{error}"))?; - messages.push(message); - } - Ok(messages) -} - -fn message_text(message: &PlanningMessageV2) -> Option { - message - .payload - .get("text") - .and_then(Value::as_str) - .map(ToString::to_string) -} - -fn build_context_v2( - root: &Path, - llm: &GameCreatorLlmConfig, - skip_client_turn_id: Option<&str>, -) -> Result, String> { - let messages = read_planning_messages_v2(root)?; - let mut estimated_chars = 0_usize; - let mut output = Vec::with_capacity(messages.len()); - for message in messages { - if skip_client_turn_id - .is_some_and(|turn_id| message.client_turn_id == turn_id && message.role == "user") - { - continue; - } - let text = message_text(&message).or_else(|| { - matches!(message.kind.as_str(), "question" | "artifact") - .then(|| serde_json::to_string(&message.payload).ok()) - .flatten() - }); - let Some(text) = text else { continue }; - estimated_chars = estimated_chars.saturating_add(text.chars().count()); - if estimated_chars > PLANNING_SESSION_V2_MAX_CONTEXT_CHARS { - return Err( - "PLANNING_CONTEXT_OVERFLOW: Planning V2 会话上下文超过有界上限".to_string(), - ); - } - let role = match message.role.as_str() { - "user" => platform_llm::LlmMessage::user(text), - "assistant" => platform_llm::LlmMessage::assistant(text), - _ => continue, - }; - output.push(role); - } - let configured_limit = llm.context_window_tokens.saturating_mul(4) as usize; - if configured_limit > 0 && estimated_chars > configured_limit { - return Err( - "PLANNING_CONTEXT_OVERFLOW: Planning V2 请求上下文超过 Provider 窗口".to_string(), - ); - } - Ok(output) -} - -fn new_session_v2(project_id: String, mode: String) -> PlanningSessionV2 { - let now = current_plan_timestamp_utc(); - PlanningSessionV2 { - schema_version: PLANNING_SESSION_V2_SCHEMA_VERSION.to_string(), - engine: PLANNING_SESSION_V2_ENGINE.to_string(), - session_id: format!("ps-{}", Uuid::new_v4().simple()), - project_id, - mode, - status: "idle".to_string(), - turn_index: 0, - question_count: 0, - question_limit: Some(8), - revision_count: 0, - current_artifact_version: None, - current_question: None, - capabilities: PlanningCapabilitySnapshotV2::default(), - processing_seconds: 0.0, - created_at_utc: now.clone(), - updated_at_utc: now, - last_error: None, - } -} - -fn read_project_id_v2(root: &Path) -> Result { - read_manifest_for_project(root) - .map(|manifest| manifest.project_id) - .map_err(|error| format!("读取项目身份失败:{error}")) -} - -fn existing_turn_result_v2( - messages: &[PlanningMessageV2], - client_turn_id: &str, -) -> Option { - messages - .iter() - .rev() - .find(|message| { - message.client_turn_id == client_turn_id - && message.role == "assistant" - && message.kind != "error" - }) - .and_then(|message| { - Some(PlanningTurnResultV2 { - schema_version: PLANNING_TURN_RESULT_V2_SCHEMA_VERSION.to_string(), - kind: "assistant_text".to_string(), - payload: message.payload.clone(), - }) - }) -} - -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.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, - client_turn_id: &str, - messages: &[PlanningMessageV2], -) -> Result, String> { - if !messages - .iter() - .any(|message| message.client_turn_id == client_turn_id && message.role == "user") - { - return Ok(None); - } - if !matches!( - session.status.as_str(), - "awaiting_approval" | "approved" | "rejected" | "revision_requested" - ) || session.current_artifact_version.is_none() - { - return Ok(None); - } - let Some(artifact) = current_planning_artifact_v2(root)? else { - return Ok(None); - }; - Ok(Some(PlanningTurnResultV2 { - schema_version: PLANNING_TURN_RESULT_V2_SCHEMA_VERSION.to_string(), - kind: "artifact".to_string(), - payload: artifact, - })) -} - -fn prepare_turn_v2( - root: &Path, - client_turn_id: &str, - prompt: &str, - mode: Option<&str>, - is_start: bool, - expected_session_id: Option<&str>, - question_id: Option<&str>, -) -> Result { - validate_project_root(root)?; - let client_turn_id = validate_client_turn_id(client_turn_id)?; - let prompt = validate_prompt(prompt)?; - // 用户提交回答或审批修改意见后的续跑是一次性意图。无等待取锁会把 - // 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 => { - let mode = mode.unwrap_or("gdd").trim(); - if mode != "gdd" { - return Err("Planning V2 当前只支持 mode=gdd".to_string()); - } - new_session_v2(read_project_id_v2(root)?, mode.to_string()) - } - }; - let project_id = read_project_id_v2(root)?; - if expected_session_id.is_some_and(|expected| session.session_id != expected.trim()) { - return Err("Planning V2 Session ID 不匹配".to_string()); - } - if session.project_id != project_id { - return Err("Planning V2 Session projectId 与当前项目不一致".to_string()); - } - if let Some(requested_mode) = mode.map(str::trim).filter(|value| !value.is_empty()) { - if requested_mode != session.mode { - return Err("Planning V2 不允许在同一 Session 切换 mode".to_string()); - } - } - if read_planning_session_v2(root)?.is_some() { - reconcile_committed_planning_gdd_v2(root)?; - if let Some(updated) = read_planning_session_v2(root)? { - session = updated; - } - } - let messages = read_planning_messages_v2(root)?; - if let Some(replay) = existing_turn_result_v2(&messages, &client_turn_id) { - return Ok(PlanningTurnStartV2 { - session, - context_messages: Vec::new(), - replay: Some(replay), - }); - } - if let Some(replay) = committed_gdd_replay_v2(root, &session, &client_turn_id, &messages)? { - return Ok(PlanningTurnStartV2 { - session, - context_messages: Vec::new(), - replay: Some(replay), - }); - } - if session.status == "planning" { - return Err("Planning V2 当前已有回合执行中".to_string()); - } - if !is_start - && !matches!( - session.status.as_str(), - "awaiting_user" | "revision_requested" | "provider_failed" - ) - { - return Err(format!( - "Planning V2 Session 当前状态不可提交用户输入:{}", - session.status - )); - } - if matches!(session.status.as_str(), "approved" | "rejected") { - return Err(format!( - "Planning V2 Session 当前状态不可继续:{}", - session.status - )); - } - if is_start && session.turn_index > 0 && messages.iter().any(|message| message.role == "user") { - return Err("Planning V2 已有会话内容,请使用 continue_planning_session_v2".to_string()); - } - let existing_user = messages - .iter() - .rev() - .find(|message| message.client_turn_id == client_turn_id && message.role == "user"); - if existing_user.is_none() { - let current_question_id = session - .current_question - .as_ref() - .and_then(|question| question.get("id")) - .and_then(Value::as_str); - if question_id != current_question_id { - return Err("待回答问题已变更,请刷新策划状态".to_string()); - } - } - let llm = resolve_game_creator_llm_config_for_agent( - &load_game_creator_app_config()?, - "planning-agent-v2", - ); - let context_messages = - build_context_v2(root, &llm, existing_user.map(|_| client_turn_id.as_str()))?; - if let Some(existing_user) = existing_user { - let existing_text = message_text(existing_user).unwrap_or_default(); - if existing_text != prompt { - return Err("同一 clientTurnId 不能提交不同用户输入".to_string()); - } - if session.status != "provider_failed" { - return Err("Planning V2 当前回合不能重复提交".to_string()); - } - session.turn_index = existing_user.turn_index; - } else { - session.turn_index = session.turn_index.saturating_add(1); - } - session.status = "planning".to_string(); - session.current_question = None; - session.last_error = None; - session.updated_at_utc = current_plan_timestamp_utc(); - if existing_user.is_none() { - append_planning_message_v2( - root, - &PlanningMessageV2 { - schema_version: PLANNING_MESSAGE_V2_SCHEMA_VERSION.to_string(), - message_id: format!("msg-{}", Uuid::new_v4().simple()), - client_turn_id: client_turn_id.clone(), - turn_index: session.turn_index, - at_utc: current_plan_timestamp_utc(), - role: "user".to_string(), - kind: "text".to_string(), - payload: serde_json::json!({"text": prompt}), - }, - )?; - } - write_planning_session_v2(root, &session)?; - Ok(PlanningTurnStartV2 { - session, - context_messages, - replay: None, - }) -} - -fn planning_v2_question_policy(session: &PlanningSessionV2) -> String { - if session.question_count >= 3 { - return "已达到 3 轮问询上限,本轮整理当前信息并调用 plan_submit_gdd。".to_string(); - } - match session.question_limit { - Some(limit) if session.question_count >= limit => format!( - "当前已向用户展示 {} 个有效问题;已达到问询上限,禁止再调用 plan_ask_question,必须调用 plan_submit_gdd 提交完整 GDD。", - session.question_count - ), - _ => format!( - "当前已向用户展示 {} 个有效问题。整个会话最多提问 3 轮;本轮可以继续问一个最重要的未决设计问题,也可以在信息足够时直接提交 GDD。", - session.question_count - ), - } -} - -fn planning_v2_system_prompt(session: &PlanningSessionV2) -> String { - // `question_limit` 是 Runtime 对“已展示问题数”的硬上限;模型提示词中的 - // 问询策略只是偏好,两者不要求数值一致。这里仅传当前已展示数, - // 达到硬上限时再明确禁止本次继续提问。 - format!( - "你是立项策划 Agent。当前会话 {},回合 {}。{}\n\n每轮必须且只能调用一个工具:问询用 plan_ask_question,出稿用 plan_submit_gdd。不要在正文输出 JSON、Markdown、解释或代码围栏。\n\n除非用户明确要求直接出稿,否则先进行关键设计澄清。\n整个会话最多提问 3 轮。\n3 轮是上限,不是配额;信息已经足够时允许 0~2 轮提前出稿。\n达到第 3 轮或信息足够时,整理当前信息并调用 plan_submit_gdd。\n审批修改后的续跑沿用本协议:可以继续问询,也可以直接提交新的完整 GDD;已确认问答和问询计数不重置。\n每轮收到用户回答后重新选择最重要的下一个未决决定,不要重复已回答的问题。\n提问优先顺序:核心行为与本局目标 → 重玩动力 → 制作边界与 MVP。\n主题包装、美术、数值和次要系统可以用 assumption_pending,answerSource 记为 agent_inferred。修改以最新用户意见为准,提交完整 GDD,不要打补丁。", - session.session_id, - session.turn_index, - planning_v2_question_policy(session), - ) -} - -fn build_provider_request_v2( - session: &PlanningSessionV2, - context_messages: Vec, - prompt: &str, - llm: &GameCreatorLlmConfig, -) -> Result { - let system = platform_llm::LlmMessage::system(planning_v2_system_prompt(session)); - let mut messages = vec![system]; - messages.extend(context_messages); - messages.push(platform_llm::LlmMessage::user(prompt.to_string())); - let api_kind = parse_game_creator_llm_api_kind(&llm.api_kind)?; - let request = platform_llm::LlmRunRequest::new(messages) - .with_api_kind(api_kind) - .with_model(llm.model.clone()) - .with_max_output_tokens(16_384) - .with_request_timeout_ms(llm.request_timeout_ms) - .with_function_tools(planning_v2_function_tools()) - // 注意:部分模型或端点可能不支持 tool_choice=required(例如思考模式下的 - // DeepSeek 会以 400 “Thinking mode does not support this tool_choice” 拒绝); - // 该 4xx 属硬错误,不进入瞬态重试。接入新模型时必须先确认端点接受 required。 - .with_tool_choice(platform_llm::LlmToolChoice::Required); - apply_game_creator_llm_reasoning_effort(request, llm) -} - -fn planning_turn_result_from_llm(response: &platform_llm::LlmRunResponse) -> PlanningTurnResultV2 { - PlanningTurnResultV2 { - schema_version: PLANNING_TURN_RESULT_V2_SCHEMA_VERSION.to_string(), - kind: "assistant_text".to_string(), - payload: serde_json::json!({ - "text": response.text, - "toolCalls": response.tool_calls.iter().map(|call| { - serde_json::json!({ - "id": call.id, - "name": call.name, - "arguments": call.arguments, - }) - }).collect::>(), - "finishReason": response.finish_reason, - "responseId": response.response_id, - "model": response.model, - }), - } -} - -struct PlanningProviderFailureV2 { - detail: String, - transient_kind: Option<&'static str>, -} - -impl PlanningProviderFailureV2 { - fn terminal(detail: String) -> Self { - Self { - detail, - transient_kind: None, - } - } -} - -async fn invoke_provider_v2( - root: &Path, - session: &PlanningSessionV2, - client_turn_id: &str, - provider_attempt: u8, - debug_call_id: &str, - prompt: &str, - context_messages: Vec, - mut on_delta: F, -) -> Result -where - F: FnMut(&str, &str, Option<&str>) + Send, -{ - let config = load_game_creator_app_config().map_err(PlanningProviderFailureV2::terminal)?; - let llm = resolve_game_creator_llm_config_for_agent(&config, "planning-agent-v2"); - let client = build_game_creator_llm_client_from_llm_config(&llm, "planning.v2") - .map_err(PlanningProviderFailureV2::terminal)?; - let request = build_provider_request_v2(session, context_messages, prompt, &llm) - .map_err(PlanningProviderFailureV2::terminal)?; - persist_planning_debug_request_v2( - root, - debug_call_id, - session, - client_turn_id, - provider_attempt, - llm.stream, - &request, - ); - if llm.stream { - let response = client - .stream_run(request, |delta| { - on_delta( - delta.accumulated_text.as_str(), - delta.delta_text.as_str(), - delta.finish_reason.as_deref(), - ); - }) - .await; - match response { - Ok(response) => { - persist_planning_debug_response_v2( - root, - debug_call_id, - session, - client_turn_id, - provider_attempt, - Some(&response), - None, - ); - Ok(planning_turn_result_from_llm(&response)) - } - Err(error) => { - let transient_kind = - game_creator_agent_runtime_transient_provider_error_kind(&error, false); - let detail = format!("Planning V2 Provider 流式调用失败:{error}"); - persist_planning_debug_response_v2( - root, - debug_call_id, - session, - client_turn_id, - provider_attempt, - None, - Some(detail.as_str()), - ); - Err(PlanningProviderFailureV2 { - detail, - transient_kind, - }) - } - } - } else { - let response = client.run(request).await; - match response { - Ok(response) => { - let text = response.text.clone(); - on_delta( - text.as_str(), - text.as_str(), - response.finish_reason.as_deref(), - ); - persist_planning_debug_response_v2( - root, - debug_call_id, - session, - client_turn_id, - provider_attempt, - Some(&response), - None, - ); - Ok(planning_turn_result_from_llm(&response)) - } - Err(error) => { - let transient_kind = - game_creator_agent_runtime_transient_provider_error_kind(&error, false); - let detail = format!("Planning V2 Provider 调用失败:{error}"); - persist_planning_debug_response_v2( - root, - debug_call_id, - session, - client_turn_id, - provider_attempt, - None, - Some(detail.as_str()), - ); - Err(PlanningProviderFailureV2 { - detail, - transient_kind, - }) - } - } - } -} - -pub(crate) fn result_text(result: &PlanningTurnResultV2) -> String { - result - .payload - .get("text") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string() -} - -fn persist_turn_failure_v2( - root: &Path, - client_turn_id: &str, - session_id: &str, - turn_index: u64, - elapsed_seconds: f64, - error: &PlanningErrorV2, -) -> Result { - 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 { - return Err("Planning V2 Session 回合身份发生变化".to_string()); - } - append_planning_message_v2( - root, - &PlanningMessageV2 { - schema_version: PLANNING_MESSAGE_V2_SCHEMA_VERSION.to_string(), - message_id: format!("msg-{}", Uuid::new_v4().simple()), - client_turn_id: client_turn_id.to_string(), - turn_index, - at_utc: current_plan_timestamp_utc(), - role: "assistant".to_string(), - kind: "error".to_string(), - payload: serde_json::json!({"code": error.code, "summary": error.summary}), - }, - )?; - session.status = "provider_failed".to_string(); - session.processing_seconds += elapsed_seconds.max(0.0); - session.updated_at_utc = current_plan_timestamp_utc(); - session.last_error = Some(error.clone()); - write_planning_session_v2(root, &session)?; - Ok(session) -} - -async fn run_turn_v2( - root: &Path, - start: PlanningTurnStartV2, - client_turn_id: String, - prompt: String, - mut emit: F, -) -> Result -where - F: FnMut(PlanningSessionStreamEventV2) + Send, -{ - if let Some(result) = start.replay { - return Ok(PlanningSessionCommandResultV2 { - session: start.session, - result: Some(result), - current_artifact: current_planning_artifact_v2(root)?, - replayed: true, - conversation: None, - }); - } - let session_id = start.session.session_id.clone(); - let turn_index = start.session.turn_index; - emit(PlanningSessionStreamEventV2 { - session_id: session_id.clone(), - client_turn_id: client_turn_id.clone(), - status: "started".to_string(), - delta_text: String::new(), - accumulated_text: String::new(), - finish_reason: None, - result: None, - error: None, - }); - let started_at = Instant::now(); - let mut accumulated = String::new(); - let mut attempt_prompt = prompt.clone(); - let mut policy_retry = 0_u8; - let mut provider_retry = 0_u32; - let mut provider_attempt = 0_u8; - let provider_retry_llm = load_game_creator_app_config() - .map(|config| resolve_game_creator_llm_config_for_agent(&config, "planning-agent-v2")) - .ok(); - let provider_max_retries = provider_retry_llm - .as_ref() - .map(|llm| llm.max_retries) - .unwrap_or(0); - let provider_retry_backoff_ms = provider_retry_llm - .as_ref() - .map(|llm| llm.retry_backoff_ms) - .unwrap_or(0); - let debug_call_id = format!("call-{}", Uuid::new_v4().simple()); - let policy_output = loop { - accumulated.clear(); - provider_attempt = provider_attempt.saturating_add(1); - let provider_result = invoke_provider_v2( - root, - &start.session, - &client_turn_id, - provider_attempt, - &debug_call_id, - &attempt_prompt, - start.context_messages.clone(), - |all, _delta, _finish_reason| { - accumulated = all.to_string(); - }, - ) - .await; - let result = match provider_result { - Ok(result) => result, - Err(failure) => { - if failure.transient_kind.is_some() && provider_retry < provider_max_retries { - provider_retry = provider_retry.saturating_add(1); - let backoff_ms = game_creator_agent_runtime_transient_retry_backoff_ms( - provider_retry_backoff_ms, - provider_retry, - ); - tokio::time::sleep(std::time::Duration::from_millis(backoff_ms)).await; - continue; - } - let detail = if failure.transient_kind.is_some() && provider_max_retries > 0 { - format!( - "{}(瞬态故障已自动重试 {}/{} 次)", - failure.detail, provider_retry, provider_max_retries - ) - } else { - failure.detail - }; - let error = safe_error("PROVIDER_FAILED", detail); - let session = persist_turn_failure_v2( - root, - &client_turn_id, - &session_id, - turn_index, - started_at.elapsed().as_secs_f64(), - &error, - )?; - emit(PlanningSessionStreamEventV2 { - session_id, - client_turn_id, - status: "failed".to_string(), - delta_text: String::new(), - accumulated_text: accumulated, - finish_reason: None, - result: None, - error: Some(error.clone()), - }); - return Ok(PlanningSessionCommandResultV2 { - session, - result: Some(PlanningTurnResultV2 { - schema_version: PLANNING_TURN_RESULT_V2_SCHEMA_VERSION.to_string(), - kind: "error".to_string(), - payload: serde_json::json!({"code": error.code, "summary": error.summary}), - }), - current_artifact: current_planning_artifact_v2(root)?, - replayed: false, - conversation: None, - }); - } - }; - match parse_planning_policy_output_v2(&result).and_then(|output| { - validate_planning_policy_output_v2(&output)?; - if matches!(output, PlanningPolicyOutputV2::Question(_)) - && start - .session - .question_limit - .is_some_and(|limit| start.session.question_count >= limit) - { - return Err( - "PLANNING_QUESTION_LIMIT: questionCount 已达到上限,必须直接输出 GDD" - .to_string(), - ); - } - Ok(output) - }) { - Ok(output) => { - persist_planning_debug_classification_v2( - root, - &debug_call_id, - &start.session, - &client_turn_id, - provider_attempt, - Some(match &output { - PlanningPolicyOutputV2::Question(_) => "question", - PlanningPolicyOutputV2::Gdd(_) => "gdd", - }), - true, - false, - None, - ); - break output; - } - Err(detail) if policy_retry < 1 => { - persist_planning_debug_classification_v2( - root, - &debug_call_id, - &start.session, - &client_turn_id, - provider_attempt, - None, - false, - true, - Some(detail.as_str()), - ); - policy_retry = policy_retry.saturating_add(1); - let retry_detail = detail.replace('\n', "\n- "); - attempt_prompt = format!( - "{}\n\n【阻断校验失败】Runtime 拒绝了上一次输出,具体原因如下:\n- {}\n请针对以上原因逐项修复,保持未涉及内容不变,并调用 plan_ask_question 或 plan_submit_gdd;不要在正文输出 JSON,不要解释。{}", - prompt, - retry_detail, - if start.session.question_limit.is_some_and(|limit| { - start.session.question_count >= limit - }) { - "当前问题数已达到上限,禁止再调用 plan_ask_question,必须调用 plan_submit_gdd。" - } else { - "" - } - ); - } - Err(detail) => { - persist_planning_debug_classification_v2( - root, - &debug_call_id, - &start.session, - &client_turn_id, - provider_attempt, - None, - false, - false, - Some(detail.as_str()), - ); - let error = safe_error("PLANNING_INVALID_OUTPUT", detail); - let session = persist_turn_failure_v2( - root, - &client_turn_id, - &session_id, - turn_index, - started_at.elapsed().as_secs_f64(), - &error, - )?; - emit(PlanningSessionStreamEventV2 { - session_id, - client_turn_id, - status: "failed".to_string(), - delta_text: String::new(), - accumulated_text: accumulated, - finish_reason: None, - result: None, - error: Some(error.clone()), - }); - return Ok(PlanningSessionCommandResultV2 { - session, - result: Some(PlanningTurnResultV2 { - schema_version: PLANNING_TURN_RESULT_V2_SCHEMA_VERSION.to_string(), - kind: "error".to_string(), - payload: serde_json::json!({"code": error.code, "summary": error.summary}), - }), - current_artifact: current_planning_artifact_v2(root)?, - replayed: false, - conversation: None, - }); - } - } - }; - if !accumulated.is_empty() { - emit(PlanningSessionStreamEventV2 { - session_id: session_id.clone(), - client_turn_id: client_turn_id.clone(), - status: "delta".to_string(), - delta_text: accumulated.clone(), - accumulated_text: accumulated.clone(), - finish_reason: None, - result: None, - error: None, - }); - } - let elapsed_seconds = started_at.elapsed().as_secs_f64(); - match persist_planning_policy_output_v2( - root, - &client_turn_id, - &session_id, - turn_index, - elapsed_seconds, - policy_output, - ) { - Ok(persisted) => { - emit(PlanningSessionStreamEventV2 { - session_id: session_id.clone(), - client_turn_id: client_turn_id.clone(), - status: "completed".to_string(), - delta_text: String::new(), - accumulated_text: if accumulated.is_empty() { - result_text(&persisted.result) - } else { - accumulated - }, - finish_reason: persisted - .result - .payload - .get("finishReason") - .and_then(Value::as_str) - .map(ToString::to_string), - result: Some(persisted.result.clone()), - error: None, - }); - Ok(PlanningSessionCommandResultV2 { - session: persisted.session, - result: Some(persisted.result), - current_artifact: persisted.current_artifact, - replayed: false, - conversation: None, - }) - } - Err(detail) => { - let repaired = { - 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 { - let session = read_planning_session_v2(root)? - .ok_or_else(|| "Planning V2 Session 不存在".to_string())?; - let artifact = current_planning_artifact_v2(root)? - .ok_or_else(|| "Planning V2 已提交 GDD 但缺少当前产物投影".to_string())?; - let result = PlanningTurnResultV2 { - schema_version: PLANNING_TURN_RESULT_V2_SCHEMA_VERSION.to_string(), - kind: "artifact".to_string(), - payload: artifact.clone(), - }; - emit(PlanningSessionStreamEventV2 { - session_id: session_id.clone(), - client_turn_id: client_turn_id.clone(), - status: "completed".to_string(), - delta_text: String::new(), - accumulated_text: if accumulated.is_empty() { - result_text(&result) - } else { - accumulated - }, - finish_reason: None, - result: Some(result.clone()), - error: None, - }); - return Ok(PlanningSessionCommandResultV2 { - session, - result: Some(result), - current_artifact: Some(artifact), - replayed: false, - conversation: None, - }); - } - let error = safe_error("PLANNING_PERSIST_FAILED", detail); - let session = persist_turn_failure_v2( - root, - &client_turn_id, - &session_id, - turn_index, - elapsed_seconds, - &error, - )?; - emit(PlanningSessionStreamEventV2 { - session_id: session_id.clone(), - client_turn_id: client_turn_id.clone(), - status: "failed".to_string(), - delta_text: String::new(), - accumulated_text: accumulated, - finish_reason: None, - result: None, - error: Some(error.clone()), - }); - Ok(PlanningSessionCommandResultV2 { - session, - result: Some(PlanningTurnResultV2 { - schema_version: PLANNING_TURN_RESULT_V2_SCHEMA_VERSION.to_string(), - kind: "error".to_string(), - payload: serde_json::json!({"code": error.code, "summary": error.summary}), - }), - current_artifact: current_planning_artifact_v2(root)?, - replayed: false, - conversation: None, - }) - } - } -} - -#[tauri::command] -pub(crate) async fn start_planning_session_v2( - app: tauri::AppHandle, - project_path: String, - client_turn_id: String, - prompt: String, - mode: Option, -) -> Result { - run_planning_session_v2_command( - &app, - project_path, - None, - client_turn_id, - prompt, - mode, - true, - None, - ) - .await -} - -#[tauri::command] -pub(crate) async fn continue_planning_session_v2( - app: tauri::AppHandle, - project_path: String, - session_id: String, - client_turn_id: String, - input: Value, -) -> Result { - let question_id = input - .get("questionId") - .and_then(Value::as_str) - .map(str::to_owned); - run_planning_session_v2_command( - &app, - project_path, - Some(session_id), - client_turn_id, - normalize_planning_input(input)?, - None, - false, - question_id, - ) - .await -} - -async fn run_planning_session_v2_command( - app: &tauri::AppHandle, - project_path: String, - expected_session_id: Option, - client_turn_id: String, - prompt: String, - mode: Option, - is_start: bool, - question_id: Option, -) -> Result { - let root = PathBuf::from(project_path.trim()); - enforce_project_permission_policy(&root, "conversation.read")?; - enforce_project_permission_policy(&root, "conversation.write")?; - let prompt = validate_prompt(&prompt)?; - let client_turn_id = validate_client_turn_id(&client_turn_id)?; - let project_id = read_project_id_v2(&root)?; - let _active_guard = try_acquire_planning_v2_active(&project_id)?; - let start = prepare_turn_v2( - &root, - &client_turn_id, - &prompt, - mode.as_deref(), - is_start, - expected_session_id.as_deref(), - question_id.as_deref(), - )?; - let event_app = app.clone(); - run_turn_v2(&root, start, client_turn_id, prompt, move |event| { - let _ = event_app.emit("planning-session-v2-stream", event); - }) - .await -} - -#[tauri::command] -pub(crate) async fn hydrate_planning_session_v2( - project_path: String, - session_id: Option, -) -> Result, 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, -) -> Result, String> { - let root = PathBuf::from(project_path.trim()); - enforce_project_permission_policy(&root, "conversation.read")?; - 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); - }; - if preflight_session.project_id != project_id { - return Err("Planning V2 Session projectId 与当前项目不一致".to_string()); - } - if let Some(session_id) = session_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - 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(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX) => { - 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 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(); - session.last_error = Some(safe_error( - "RECOVERY_REQUIRED", - "上次 Planning V2 Provider 回合在进程退出前未完成,请重新提交用户输入", - )); - } - session.updated_at_utc = current_plan_timestamp_utc(); - write_planning_session_v2(&root, &session)?; - } - reconcile_committed_planning_gdd_v2(&root)?; - let session = - read_planning_session_v2(&root)?.ok_or_else(|| "Planning V2 Session 不存在".to_string())?; - Ok(Some(PlanningSessionCommandResultV2 { - session, - result: None, - current_artifact: current_planning_artifact_v2(&root)?, - replayed: false, - conversation: Some(read_planning_messages_v2(&root)?), - })) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn client_turn_id_and_prompt_are_trimmed_and_bounded() { - assert_eq!(validate_client_turn_id(" turn-1 ").unwrap(), "turn-1"); - assert_eq!(validate_prompt(" 做方案 ").unwrap(), "做方案"); - assert!(validate_client_turn_id(&"x".repeat(129)).is_err()); - assert!(validate_prompt(" ").is_err()); - } - - #[test] - fn existing_turn_result_replays_assistant_text_or_error() { - let messages = vec![PlanningMessageV2 { - schema_version: PLANNING_MESSAGE_V2_SCHEMA_VERSION.to_string(), - message_id: "msg-1".to_string(), - client_turn_id: "turn-1".to_string(), - turn_index: 1, - at_utc: "2026-09-03T00:00:00.000Z".to_string(), - role: "assistant".to_string(), - kind: "text".to_string(), - payload: serde_json::json!({"text": "已完成"}), - }]; - let result = existing_turn_result_v2(&messages, "turn-1").expect("replay result"); - assert_eq!(result.kind, "assistant_text"); - assert_eq!(result_text(&result), "已完成"); - } - - #[test] - fn error_message_is_not_treated_as_successful_idempotent_replay() { - let messages = vec![PlanningMessageV2 { - schema_version: PLANNING_MESSAGE_V2_SCHEMA_VERSION.to_string(), - message_id: "msg-1".to_string(), - client_turn_id: "turn-1".to_string(), - turn_index: 1, - at_utc: "2026-09-03T00:00:00.000Z".to_string(), - role: "assistant".to_string(), - kind: "error".to_string(), - payload: serde_json::json!({"code": "PROVIDER_FAILED"}), - }]; - assert!(existing_turn_result_v2(&messages, "turn-1").is_none()); - } - - #[test] - fn session_validation_rejects_unknown_status_or_schema() { - let mut session = new_session_v2("project-1".to_string(), "gdd".to_string()); - assert!(validate_planning_session_v2(&session).is_ok()); - session.status = "unknown".to_string(); - assert!(validate_planning_session_v2(&session).is_err()); - session.status = "idle".to_string(); - session.schema_version = "planning-session.v1".to_string(); - assert!(validate_planning_session_v2(&session).is_err()); - } - - #[test] - fn continue_input_accepts_contract_shaped_option_payload() { - assert_eq!( - normalize_planning_input(serde_json::json!({ - "kind": "option", - "optionIndex": 0, - "optionLabel": "工作台设计迭代" - })) - .unwrap(), - "按选项:工作台设计迭代" - ); - assert_eq!( - normalize_planning_input(serde_json::json!({ "optionIndex": 1 })).unwrap(), - "按第 2 个选项做" - ); - } - - 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) - } - - fn hold_project_lock_briefly(root: &Path, hold_millis: u64) -> std::thread::JoinHandle<()> { - let lock_path = root.join(".agent/project.lock"); - fs::write(&lock_path, b"test 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 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, None, None) - .expect("修订续跑必须等过瞬时锁争用,而不是把失败甩回总控"); - holder.join().expect("lock holder thread"); - assert_eq!(start.session.status, "planning"); - assert!(start.replay.is_none()); - } - - #[test] - fn question_answer_must_target_the_persisted_question() { - let (_dir, root, mut session) = v2_revision_session_fixture(); - session.status = "awaiting_user".to_string(); - session.current_question = Some(serde_json::json!({"id": "question-2"})); - write_planning_session_v2(&root, &session).unwrap(); - for question_id in [Some("question-1"), None] { - let result = prepare_turn_v2( - &root, - "late-answer", - "选择第一个", - None, - false, - Some(&session.session_id), - question_id, - ); - assert!(matches!(result, Err(error) if error.contains("待回答问题已变更"))); - let persisted = read_planning_session_v2(&root).unwrap().unwrap(); - assert_eq!(persisted.status, "awaiting_user"); - assert_eq!(persisted.turn_index, session.turn_index); - assert_eq!(persisted.current_question, session.current_question); - assert!(read_planning_messages_v2(&root).unwrap().is_empty()); - } - let result = prepare_turn_v2( - &root, - "current-answer", - "选择第一个", - None, - false, - Some(&session.session_id), - Some("question-2"), - ) - .expect("当前问题回答应正常推进"); - assert_eq!(result.session.status, "planning"); - } - - #[test] - fn turn_rejects_a_different_session_before_writing() { - let (_dir, root, session) = v2_revision_session_fixture(); - let result = prepare_turn_v2( - &root, - "wrong-session", - "加强节奏", - None, - false, - Some("old-session"), - None, - ); - assert!(matches!(result, Err(error) if error.contains("Session ID 不匹配"))); - assert_eq!( - read_planning_session_v2(&root).unwrap().unwrap().turn_index, - session.turn_index - ); - assert!(read_planning_messages_v2(&root).unwrap().is_empty()); - } - - #[test] - fn completed_answer_replays_after_question_changes() { - let (_dir, root, mut session) = v2_revision_session_fixture(); - session.status = "awaiting_user".to_string(); - session.current_question = Some(serde_json::json!({"id": "question-2"})); - write_planning_session_v2(&root, &session).unwrap(); - append_planning_message_v2( - &root, - &PlanningMessageV2 { - schema_version: PLANNING_MESSAGE_V2_SCHEMA_VERSION.to_string(), - message_id: "completed-answer".to_string(), - client_turn_id: "answer-1".to_string(), - turn_index: 1, - at_utc: current_plan_timestamp_utc(), - role: "assistant".to_string(), - kind: "text".to_string(), - payload: serde_json::json!({"text": "已处理"}), - }, - ) - .unwrap(); - let result = prepare_turn_v2( - &root, - "answer-1", - "选择第一个", - None, - false, - Some(&session.session_id), - Some("question-1"), - ) - .expect("已完成回答继续重放,不再次推进"); - assert!(result.replay.is_some()); - assert_eq!(result.session.turn_index, session.turn_index); - assert_eq!(result.session.current_question, session.current_question); - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 3813a0eb6..e01525ffa 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2628,10 +2628,6 @@ fn main() { chat_with_game_creator_role_agent, chat_with_game_creator_role_agent_stream, chat_with_game_creator_direct_codex, - start_planning_session_v2, - continue_planning_session_v2, - decide_planning_artifact_v2, - hydrate_planning_session_v2, hydrate_design_agent_session, reset_design_agent_session, get_design_agent_runtime_mode, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs b/apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs index f615b2ddb..fa67942f9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs @@ -372,7 +372,7 @@ pub(crate) fn project_write_lock_reclaim( } /// `.agent/project.lock` 的争用错误前缀。`project_gates.rs`、`provider_recovery.rs`、 -/// `planning_session_v2.rs`、`direct_runtime.rs` 和前端 `App.tsx` 都按这个前缀把争用 +/// `direct_runtime.rs` 和前端 `App.tsx` 都按这个前缀把争用 /// 识别成"可以等一下"的瞬时状态;文案扩展时要保持前缀逐字不变。 pub(crate) const PROJECT_WRITE_LOCK_CONTENTION_PREFIX: &str = "项目正在被其他写操作占用:"; @@ -476,7 +476,7 @@ impl ProjectWriteLockFailure { } /// 零等待入口的文案。可重试的失败保持争用前缀逐字不变:`provider_recovery.rs`、 - /// `planning_session_v2.rs`、`direct_runtime.rs` 和前端 `App.tsx` 都按这个前缀把错误 + /// `direct_runtime.rs` 和前端 `App.tsx` 都按这个前缀把错误 /// 当成可等待的瞬时状态,改前缀等于顺手改掉它们的重试语义。 pub(crate) fn message(&self) -> String { match self { diff --git a/docs/project-memory/plans/【实施计划】退役策划V2 Rust Runtime清理-2026-09-14.md b/docs/project-memory/plans/【实施计划】退役策划V2 Rust Runtime清理-2026-09-14.md new file mode 100644 index 000000000..ebe9f1d74 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】退役策划V2 Rust Runtime清理-2026-09-14.md @@ -0,0 +1,21 @@ +# 关联里程碑 + +`【里程碑】退役策划V2 Rust Runtime清理-2026-09-14.md` + +# 修改顺序 + +1. 从 `runtime_protocol.rs` 移除 V2 模块声明与导出。 +2. 从 `main.rs` / `commands.rs` 移除 V2 command 注册和仅供 V2 的导入。 +3. 删除 V2 Rust 模块及其专属单元测试;保留共享 GDD 模型或新版设计会话仍使用的类型。 +4. 用 `rg` 检查 V2 Rust 符号残留,修复编译引用。 + +# 验证命令 + +- `cargo check --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml` +- `npm run check:encoding` +- `git diff --check` + +# 风险与回滚 + +- 风险:V2 类型可能被共享测试或前端桥接代码引用。处理方式是按编译错误逐项判断,保留真正共享类型。 +- 回滚:按提交粒度回退本里程碑提交,不触碰前序 V1 清理提交。 diff --git a/docs/project-memory/plans/【里程碑】退役策划V2 Rust Runtime清理-2026-09-14.md b/docs/project-memory/plans/【里程碑】退役策划V2 Rust Runtime清理-2026-09-14.md new file mode 100644 index 000000000..8fcab81ad --- /dev/null +++ b/docs/project-memory/plans/【里程碑】退役策划V2 Rust Runtime清理-2026-09-14.md @@ -0,0 +1,38 @@ +# Version + +V2-RUST-RETIRE-1 + +# Status + +in-progress + +# Date + +2026-09-14 + +# Parent Spec + +`docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md` + +# 目标 + +删除已经被独立 Design Agent 取代的旧策划 V2 Rust Runtime、Tauri 命令注册和仅服务 V2 的模块导出,使桌面壳继续编译并保留做游戏 Agent 与新版 Design Agent。 + +# 边界 + +- 删除 `planning_policy_v2`、`planning_session_v2` 及仅供这两者使用的 V2 注册和调用。 +- 删除 V2 专属的 Tauri command 注册、模块导出和测试入口。 +- 保留 `design_runtime`、`design_tools`、`design_session`、通用 runtime、DirectProject 和做游戏 Agent。 +- 本里程碑不处理前端 V2 数据层、UI、文档索引和共享运行时中的可选清理。 + +# 验收标准 + +1. Rust 源码不再编译 `planning_policy_v2.rs` 或 `planning_session_v2.rs`。 +2. `main.rs`、`commands.rs` 和 runtime protocol 不再注册或导出 V2 命令。 +3. 新版 Design Agent 与做游戏 Agent 的 Rust 编译路径保持可用。 +4. 相关定向 Rust 测试和 `cargo check` 通过。 + +# 依赖 + +- 当前分支已包含 PR159 的 V1 清理。 +- 前端 V2 调用暂时保留,待后续里程碑同步删除。