修复策划 V2 孤儿 GDD 无法恢复
把 gdd.vN.json 创建成功当作提交点 persist、hydrate 与回合启动认领下一连续版本并补投影 重试不再用新 UUID 覆盖同一版本 补充孤儿认领与下一版本分配测试
This commit is contained in:
+361
-45
@@ -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,
|
||||
"第二版守夜者"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+81
@@ -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`/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`。
|
||||
|
||||
@@ -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 等字段。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user