修复策划V2修改意见撞项目锁

V2 审批、续跑、落盘、失败投影和 GDD 认领改为完整等待窗口
V2 hydrate 改为短窗口等待
前端 V2 hydrate 忽略瞬时锁争用
补充审批、续跑、hydrate 锁等待测试
同步决策、踩坑和技术方案
This commit is contained in:
2026-09-05 11:28:37 +00:00
parent 4127686e18
commit 2d7226dad8
6 changed files with 179 additions and 7 deletions
@@ -1222,7 +1222,12 @@ pub(crate) fn persist_planning_policy_output_v2(
elapsed_seconds: f64,
output: PlanningPolicyOutputV2,
) -> Result<PlanningPolicyPersistedV2, String> {
let _lock = acquire_project_write_lock(root, "planning.v2.policy.persist")?;
// 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 {
@@ -1382,7 +1387,12 @@ pub(crate) fn decide_planning_artifact_v2_at(
root: &Path,
input: DecidePlanningArtifactV2Input,
) -> Result<PlanningApprovalCommandResultV2, String> {
let _lock = acquire_project_write_lock(root, "planning.v2.approval")?;
// 审批按钮是一次性意图。修订后续跑和 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;
@@ -1929,4 +1939,66 @@ mod tests {
"第二版守夜者"
);
}
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);
}
}
@@ -655,7 +655,12 @@ fn prepare_turn_v2(
validate_project_root(root)?;
let client_turn_id = validate_client_turn_id(client_turn_id)?;
let prompt = validate_prompt(prompt)?;
let _lock = acquire_project_write_lock(root, "planning.v2.turn.start")?;
// 用户提交回答或审批修改意见后的续跑是一次性意图。无等待取锁会把
// 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 => {
@@ -955,7 +960,10 @@ fn persist_turn_failure_v2(
elapsed_seconds: f64,
error: &PlanningErrorV2,
) -> Result<PlanningSessionV2, String> {
let _lock = acquire_project_write_lock(root, "planning.v2.turn.fail")?;
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 {
@@ -1224,7 +1232,10 @@ where
}
Err(detail) => {
let repaired = {
let _lock = acquire_project_write_lock(root, "planning.v2.gdd.reconcile")?;
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 {
@@ -1363,7 +1374,12 @@ pub(crate) fn hydrate_planning_session_v2(
) -> Result<Option<PlanningSessionCommandResultV2>, String> {
let root = PathBuf::from(project_path.trim());
enforce_project_permission_policy(&root, "conversation.read")?;
let _lock = acquire_project_write_lock(&root, "planning.v2.hydrate")?;
// GUI 在审批落盘后会立刻重灌。短窗口等过瞬时争用;下一拍轮询还会再跑,
// 不能占满完整写锁等待把面板卡住。
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_short_wait(
&root,
"planning.v2.hydrate",
)?;
let Some(mut session) = read_planning_session_v2(&root)? else {
return Ok(None);
};
@@ -1478,4 +1494,64 @@ mod tests {
"按第 2 个选项做"
);
}
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");
})
}
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)
}
#[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)
.expect("修订续跑必须等过瞬时锁争用,而不是把失败甩回总控");
holder.join().expect("lock holder thread");
assert_eq!(start.session.status, "planning");
assert!(start.replay.is_none());
}
#[test]
fn hydrate_rides_out_a_briefly_held_project_lock() {
let (_dir, root, session) = v2_revision_session_fixture();
let holder = hold_project_lock_briefly(&root, 120);
let hydrated = hydrate_planning_session_v2(
root.to_string_lossy().to_string(),
Some(session.session_id.clone()),
)
.expect("hydrate 必须等过瞬时锁争用")
.expect("session");
holder.join().expect("lock holder thread");
assert_eq!(hydrated.session.session_id, session.session_id);
assert_eq!(hydrated.session.status, "revision_requested");
}
}
+8 -1
View File
@@ -724,7 +724,14 @@ export function App({
try {
await hydratePlanningV2Session(targetProjectPath);
} catch (error) {
if (requestSequence === planGddHydrateSequenceRef.current) {
// 与旧 hydrate 相同:项目写锁争用是瞬时的。V2 审批修改后会立刻续跑并
// 重灌,下一拍还能拿到;把占用画进错误位会让刚提交的修改意见看起来失败。
const transientContention =
String(error).includes('项目正在被其他写操作占用:');
if (
!transientContention &&
requestSequence === planGddHydrateSequenceRef.current
) {
setPlanGddError(String(error));
}
} finally {