修复策划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 {
@@ -15,6 +15,14 @@
- 关联文档:相关 PRD、技术文档、提交或 Issue
```
## 2026-09-05 Planning V2 一次性写路径对齐 V1 的项目锁等待窗口
- 背景:V2 审批修改意见后立即 `continue_planning_session_v2`。审批、回合启动、策略落盘和 hydrate 原先无等待取锁,和 GUI 重灌或其它写操作撞车就返回 `项目正在被其他写操作占用`,前端再映射成总控失败。V1 已用完整/短窗口处理同一形状。
- 决策:V2 审批、回合启动、策略落盘、失败投影和 GDD 认领使用完整等待窗口;V2 hydrate 使用短窗口。不引入可重入项目锁,不放宽失效回收。
- 影响范围:`planning_policy_v2.rs``planning_session_v2.rs`、V2 hydrate 前端瞬时争用处理。
- 验证方式:Rust 定向测试覆盖短暂占用下的审批、修订续跑和 hydrate。
- 关联文档:`docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md``docs/project-memory/shared-memory/pitfalls.md`
## 2026-09-05 本进程新建 Windows 私有对象不因继承 DACL 自动 UAC
- 背景:#211 要求 sidecar 满足当前用户独占、禁止继承的 DACL。新建文件会先继承父目录 ACE,生产路径把这种短暂不合格送进 UAC;`project.lock` 还在独占句柄上 harden。含空格项目路径上提权 ArgumentList 被拆开,修复以 exit 1 失败。GDD 审批改意见因此弹权限,V1 锁创建不会。
@@ -2,6 +2,14 @@
> 当前口径:本文件保留可复用的排障经验;历史条目的旧路由、旧版本和已删除文档仅作根因背景,不得据此恢复退役入口。当前命令、路由和 schema 以代码与 `docs/README.md` 为准。
## 2026-09-05 Planning V2 审批和续跑必须等过项目锁瞬时争用
- **现象**:策划 V2 在 GDD 审批提交修改意见后提示 `项目正在被其他写操作占用:...\\.agent\\project.lock`,聊天区再出现 `项目总控 Agent 执行失败,请稍后重试`
- **原因**V1 `decide_plan_gdd_at` / hydrate 已按完整或短窗口等待项目锁。V2 的审批、回合启动、策略落盘和 hydrate 直接 `acquire_project_write_lock`,与 GUI 重灌、刚结束的审批写盘或后台扫描撞车就立刻失败。修订后续跑走 `continue_planning_session_v2`,失败被前端写进总控错误位。这不是锁没释放,也不是 UAC。
- **处理**:一次性用户意图(审批、回合启动、策略落盘、失败投影、GDD 认领)走完整等待窗口;V2 hydrate 走短窗口。前端 V2 hydrate 对锁争用保持上一份状态,不把瞬时占用画进审批卡。
- **排查顺序**:先看错误是否点名 `project.lock` 且发生在提交修改意见或立刻续跑;不要当成总控 Runtime 或 Provider 失败。锁文件在失败后通常已被 Drop 删掉,现场缺文件不否定争用。
- **验证**:Rust 定向覆盖 V2 审批、修订续跑和 hydrate 等过短暂占用的项目锁。
## 2026-09-05 新建项目锁不要把继承 DACL 当成 UAC 事件
- **现象**:策划 V2 在 GDD 审批提交修改意见时弹出权限窗口,目标是 `Documents\Genarrative GameAgent\gameagent-*\.agent\project.lock`,随后 `AGC ACL 提权修复未成功(exit code Some(1)`
@@ -937,6 +937,7 @@ P3 之后的协议修正:V2 不再用正文 JSON 输出问询/GDDProvider
| -------------------------------------------------------- | ----------------------------------------------------------------- |
| 继续复用旧 `planning_submit.rs` 导致 Supervisor 身份回流 | V2 使用独立 artifact/approval 模块;只复用通用文件/锁能力 |
| 新旧都写 `game/fast_gdd.md` | 同一项目单活跃策划权威;V2/旧路径均使用项目锁和原子写 |
| 审批修改后立刻续跑与 hydrate 抢同一把项目锁 | 一次性用户意图走完整等待窗口,V2 hydrate 走短窗口,对齐 V1;不引入可重入锁 |
| 无限会话导致上下文无限膨胀 | 当前先分离完整记录和 ContextBuilder;超预算显式失败,后续再加摘要 |
| 未来 MCP/Skill 侵入 GDD 策略 | 能力快照和消息类型在 Runtime 层预留,当前策略不广告、不执行 |
| 强制失败导致旧 pending/receipt 不再可继续 | 这是本次明确的退役语义;旧文件只读保留,不迁移、不删除 |