修复策划 V2 孤儿 GDD 无法恢复
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled

把 gdd.vN.json 创建成功当作提交点

persist、hydrate 与回合启动认领下一连续版本并补投影

重试不再用新 UUID 覆盖同一版本

补充孤儿认领与下一版本分配测试
This commit is contained in:
2026-09-04 10:47:17 +00:00
parent c1d967b17d
commit ca62243628
5 changed files with 460 additions and 46 deletions
@@ -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<PlanningGddV2, String> {
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<Option<PlanningGddV2>, 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<PlanningGddV2, String> {
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<u32, String> {
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>), 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<String, String> {
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<PlanningPolicyPersistedV2, String> {
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<Option<PlanningGddV2>, 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<PlanningGddIndexV2, String> {
@@ -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::<Vec<_>>();
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,
"第二版守夜者"
);
}
}
@@ -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<Option<PlanningTurnResultV2>, 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,
@@ -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 不能同时使用。
@@ -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`/vNhydrate 能看到当前产物。
## 2026-09-04 DeepSeek thinking 不能与 tool_choice=required 同时使用
- **现象**DeepSeek V4(默认 thinking)对 `tool_choice=required` 或指定函数返回 HTTP 400`Thinking mode does not support this tool_choice`
@@ -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 等字段。