修复策划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 {
|
||||
|
||||
@@ -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 输出问询/GDD;Provider
|
||||
| -------------------------------------------------------- | ----------------------------------------------------------------- |
|
||||
| 继续复用旧 `planning_submit.rs` 导致 Supervisor 身份回流 | V2 使用独立 artifact/approval 模块;只复用通用文件/锁能力 |
|
||||
| 新旧都写 `game/fast_gdd.md` | 同一项目单活跃策划权威;V2/旧路径均使用项目锁和原子写 |
|
||||
| 审批修改后立刻续跑与 hydrate 抢同一把项目锁 | 一次性用户意图走完整等待窗口,V2 hydrate 走短窗口,对齐 V1;不引入可重入锁 |
|
||||
| 无限会话导致上下文无限膨胀 | 当前先分离完整记录和 ContextBuilder;超预算显式失败,后续再加摘要 |
|
||||
| 未来 MCP/Skill 侵入 GDD 策略 | 能力快照和消息类型在 Runtime 层预留,当前策略不广告、不执行 |
|
||||
| 强制失败导致旧 pending/receipt 不再可继续 | 这是本次明确的退役语义;旧文件只读保留,不迁移、不删除 |
|
||||
|
||||
Reference in New Issue
Block a user