diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_policy_v2.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_policy_v2.rs index 43311f189..9e56f6b2c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_policy_v2.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_policy_v2.rs @@ -832,8 +832,19 @@ fn create_v2_immutable_file(root: &Path, relative: &str, bytes: &[u8]) -> Result } 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")); - prepare_game_creator_private_path_for_read(&path, false, "Planning V2 GDD")?; + 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 { @@ -848,7 +859,142 @@ fn read_gdd_v2(root: &Path, version: u32) -> Result { if gdd.version != version { return Err("PLANNING_INVALID_GDD: GDD 文件名与 version 不一致".to_string()); } - Ok(gdd) + 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" | "stopped") { + 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 { @@ -1080,50 +1226,24 @@ pub(crate) fn persist_planning_policy_output_v2( }) } PlanningPolicyOutputV2::Gdd(input) => { - let next_version = session - .current_artifact_version - .map(|value| value.saturating_add(1)) - .unwrap_or(1); - let next_version = u32::try_from(next_version) - .map_err(|_| "PLANNING_VERSION_LIMIT: GDD 版本超出范围".to_string())?; - 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)?; - update_index_v2(root, &gdd, "ready_for_approval", None)?; - let markdown = render_gdd_v2_markdown(&gdd, "ready_for_approval")?; - write_plan_fast_gdd_markdown_atomic_locked(root, &markdown) - .map_err(|error| error.to_string())?; - let artifact = artifact_value_v2(&gdd, "ready_for_approval"); - append_planning_message_v2( + 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, - &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 = "awaiting_approval".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: "artifact".to_string(), - payload: artifact.clone(), - }, - current_artifact: Some(artifact), - }) + &mut session, + client_turn_id, + turn_index, + elapsed_seconds, + &gdd, + ) } } } @@ -1534,4 +1654,200 @@ mod tests { .expect("game properties") .contains_key("title")); } + + 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( + 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, + "第二版守夜者" + ); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_session_v2.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_session_v2.rs index a6bb19ee2..779c86293 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_session_v2.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_session_v2.rs @@ -441,6 +441,35 @@ fn has_successful_assistant_for_turn(messages: &[PlanningMessageV2], turn_index: }) } +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, @@ -471,6 +500,12 @@ fn prepare_turn_v2( 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 { @@ -479,6 +514,13 @@ fn prepare_turn_v2( 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()); } @@ -902,6 +944,42 @@ where }) } Err(detail) => { + let repaired = { + let _lock = acquire_project_write_lock(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, @@ -1038,6 +1116,9 @@ pub(crate) fn hydrate_planning_session_v2( 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, diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 5637c017b..76c56f173 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -15,6 +15,14 @@ - 关联文档:相关 PRD、技术文档、提交或 Issue ``` +## 2026-09-04 Planning V2 把 `gdd.vN.json` 创建成功当作提交点 + +- 背景:V2 persist 先 create-only 写入不可变 GDD,再更新 index、Markdown、conversation 和 session。后续任一步失败会把 session 标成 `provider_failed`,但不回滚已创建文件;重试会重新生成 UUID/时间戳并撞上“已存在且内容不同”,hydrate 又只信 `current_artifact_version`,项目会卡死。 +- 决策:`gdd.v{N}.json` 创建成功即提交点,禁止回滚不可变文件。persist / hydrate / 回合启动若发现 session 指针的下一个连续版本已在磁盘,必须读取既有 GDD 补投影,不得用新的 LLM 入参重建身份。session 指针写成功前的投影失败仍可返回 persist 错误,但恢复路径必须认领该版本。 +- 影响范围:`planning_policy_v2.rs` persist/认领、`planning_session_v2.rs` hydrate 与回合启动;V2 技术方案。 +- 验证方式:Rust 测试覆盖孤儿 GDD 重试认领、hydrate 认领、成功提交后仍分配下一版本;`cargo fmt --check`、`npm run check:encoding`、`git diff --check`。 +- 关联文档:`docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md`。 + ## 2026-09-04 PlanningSessionRuntime V2 用协议工具输出问询和 GDD - 背景:原型已验证 `plan_ask_question` / `plan_submit_gdd` 两个协议工具、深层 schema、提示词只留策略、`tool_choice=auto` 可跑通;生产 V2 仍解析正文 `{kind,question|gdd}` JSON,并把形状骨架写在 system prompt 里。浅 schema + 正文 JSON 会误导模型把 GDD 写成普通文本;`tool_choice=required` 与 DeepSeek thinking 不能同时使用。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 492963658..6793a3707 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2,6 +2,13 @@ > 当前口径:本文件保留可复用的排障经验;历史条目的旧路由、旧版本和已删除文档仅作根因背景,不得据此恢复退役入口。当前命令、路由和 schema 以代码与 `docs/README.md` 为准。 +## 2026-09-04 Planning V2 不可变 GDD 创建后不能当没提交 + +- **现象**:`gdd.vN.json` 已 create-only 落盘,但 index / Markdown / conversation / session 任一步失败后,session 停在 `provider_failed` 且 `current_artifact_version` 仍指向旧版本。重试会用新 UUID/时间戳再写同一版本号,命中“已存在且内容不同”。 +- **处理**:把该文件当作提交点。恢复时只认领 session 指针的下一个连续版本并补投影,不要删文件,也不要重建 GDD 身份。hydrate 和同一回合重试都必须走这条认领路径。 +- **排查顺序**:先看 `.agent/planning-v2/gdd.vN.json` 是否已存在、再看 `session.json` 的 `currentArtifactVersion` 是否落后;不要为了重试去覆盖不可变文件。 +- **验证**:孤儿文件重试后仍是同一 `gddId`/vN,hydrate 能看到当前产物。 + ## 2026-09-04 DeepSeek thinking 不能与 tool_choice=required 同时使用 - **现象**:DeepSeek V4(默认 thinking)对 `tool_choice=required` 或指定函数返回 HTTP 400:`Thinking mode does not support this tool_choice`。 diff --git a/docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md b/docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md index 94efd332b..c4e2ec6b4 100644 --- a/docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md +++ b/docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md @@ -454,6 +454,8 @@ game/fast_gdd.md 该路径是当前 UI 和后续“做成游戏”入口的稳定交付面;V2 写入时必须使用项目写锁、临时文件和原子替换。 +`gdd.v{N}.json` 的 create-only 写入是提交点。index、`game/fast_gdd.md`、conversation 和 session 指针都是投影:任一投影失败不得回滚已创建的 GDD,也不得用新的 UUID/时间戳重写同一版本。hydrate 与同一回合重试必须认领 session 指针的下一个连续版本并补投影;只有磁盘上还不存在该版本文件时,才根据本轮入参新建。 + ### 6.2 V2 GDD 与审批 P0 冻结 V2 GDD 使用 `plan-gdd.v2`,只保存业务内容和 V2 自身身份: @@ -551,7 +553,7 @@ hydrate_planning_session_v2 - `start_planning_session_v2`:创建或幂等启动 V2 Session,并提交首条用户需求。 - `continue_planning_session_v2`:提交用户对 question 的回答,或提交审批修改意见后的修订指令;重启恢复不单独创建 `resume` 命令。 - `decide_planning_artifact_v2`:提交当前最新产物的批准、修改或退回决定。 -- `hydrate_planning_session_v2`:只读返回 V2 Session、当前产物和当前等待态。 +- `hydrate_planning_session_v2`:返回 V2 Session、当前产物和当前等待态;若发现已提交但未投影的连续 GDD 版本,在项目写锁内认领并补投影。 命令只接收项目路径、Session 标识、稳定 client turn、用户文本/选项和 V2 产物身份;不接收或生成 Supervisor 根 Run、delegation、acceptance evidence 等字段。