修复策划V2修改意见撞项目锁
V2 审批、续跑、落盘、失败投影和 GDD 认领改为完整等待窗口 V2 hydrate 改为短窗口等待 前端 V2 hydrate 忽略瞬时锁争用 补充审批、续跑、hydrate 锁等待测试 同步决策、踩坑和技术方案
This commit is contained in:
+74
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+80
-4
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user