修复 AGC Direct 写通道项目锁零等待与持锁方不可诊断
Project CI / Repository checks (pull_request) Successful in 3m9s
Project CI / Frontend tests (pull_request) Successful in 4m7s
Project CI / Backend tests (pull_request) Successful in 6m54s
Project CI / Native shell tests (pull_request) Failing after 14m11s

- Direct 写路径(agc_write_file)改用统一的有界等待,与 file.write / file.patch / file.delete 同语义,同一轮并行写多个文件按同一把锁串行,不再在 24-42ms 内把重叠判成"项目正在被其他写操作占用"
- create_new 失败拆成争用 / 权限拒绝 / 其它三类:锁文件不存在却仍创建失败不再投影成争用;sharing violation(32) 与 lock violation(33) 恒定归争用
- 争用错误与等待日志带上持锁方身份(commandId / pid / createdAt / ownerIsSelf),锁文件处于删除挂起或未写完时显式表达成"身份不可读"
- ProjectWriteLockSnapshot 补 commandId 与 describe_holder(),沿用既有快照 + 字节 CAS 回收机制,不新增第二套回收判据
- 有界等待预算耗尽记 project.write_lock.wait_exhausted(含等待毫秒数),权限拒绝记 project.write_lock.permission_denied,争用不在零等待入口里逐次记账
- 新增同进程重叠写等待、同轮并行写、活外部进程持锁带身份、权限拒绝分类、ACL 拒绝不投影成争用五条回归用例
- 同步 decision-log、pitfalls 与技术方案文档
This commit is contained in:
2026-09-10 16:30:22 +08:00
parent a1b9b24891
commit 81c2389132
7 changed files with 405 additions and 20 deletions
@@ -1473,7 +1473,14 @@ fn bridge_write_file(root: &Path, arguments: &Value) -> Value {
return Err("工具参数 content 不能包含 NUL".to_string());
}
reject_command_output_wrapper(content)?;
let _lock = acquire_project_write_lock(root, "direct-codex.file.write")?;
// Direct 写入原本用零等待取锁:任何重叠都在 24-42ms 内直接被判成"别人正在写",
// 而 `file.write / file.patch / file.delete` 等写入口用的是约 10 秒有界等待。
// 这是用户直接触发、失败即整轮无法落盘的项目写入通道,必须和其它写入口同语义:
// 短暂重叠排队等成功,只有预算耗尽才报出带持锁方身份的错误。
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
"direct-codex.file.write",
)?;
let written = write_local_project_file_at(root, &path, content)?;
let revision = advance_agent_runtime_project_revision_locked(root)?;
Ok::<_, String>(json!({
@@ -2712,6 +2719,127 @@ mod tests {
);
}
/// Issue #318 第 1 条验收:同一轮里并行的多个文件写必须排队成功,
/// 而不是互相报"项目正在被其他写操作占用"。
#[test]
fn bridge_write_file_serializes_parallel_writes_in_one_round() {
let temporary = tempfile::tempdir().expect("create parallel direct write root");
init_local_game_project_at(temporary.path(), "direct-parallel", "Direct 并行写入测试")
.expect("initialize parallel direct write root");
let root = temporary.path().to_path_buf();
let paths = (0..4)
.map(|index| format!("game/parallel-{index}.js"))
.collect::<Vec<_>>();
let results = std::thread::scope(|scope| {
let handles = paths
.iter()
.map(|path| {
let root = root.clone();
let path = path.clone();
scope.spawn(move || {
let result = bridge_write_file(
&root,
&json!({ "path": path, "content": format!("// {path}\n") }),
);
(path, result)
})
})
.collect::<Vec<_>>();
handles
.into_iter()
.map(|handle| handle.join().expect("parallel direct write must not panic"))
.collect::<Vec<_>>()
});
for (path, result) in &results {
assert_eq!(
result.get("isError").and_then(Value::as_bool),
Some(false),
"parallel direct write of {path} must succeed: {result}"
);
assert_eq!(
fs::read_to_string(root.join(path)).expect("read parallel direct write"),
format!("// {path}\n")
);
}
}
/// Issue #318 第 1 条验收:App 自己另一条写通道正在写该项目时,
/// Direct 写入必须等待后成功,而不是在 24-42ms 内被判成"别人正在写"。
#[test]
fn bridge_write_file_waits_for_a_short_same_process_project_writer() {
let temporary = tempfile::tempdir().expect("create contended direct write root");
init_local_game_project_at(temporary.path(), "direct-contended", "Direct 写入等待测试")
.expect("initialize contended direct write root");
let root = temporary.path().to_path_buf();
let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
let holder_barrier = std::sync::Arc::clone(&barrier);
let holder_root = root.clone();
let holder = std::thread::spawn(move || {
let lock = acquire_project_write_lock(&holder_root, "concurrent-writer")
.expect("acquire a short-lived project writer");
holder_barrier.wait();
std::thread::sleep(std::time::Duration::from_millis(300));
drop(lock);
});
barrier.wait();
let result = bridge_write_file(
&root,
&json!({ "path": "game/waited.js", "content": "// waited\n" }),
);
holder.join().expect("join the short-lived project writer");
assert_eq!(
result.get("isError").and_then(Value::as_bool),
Some(false),
"the direct write must wait out a short same-process writer: {result}"
);
assert_eq!(
fs::read_to_string(root.join("game/waited.js")).expect("read waited direct write"),
"// waited\n"
);
}
/// Issue #318 第 3 条验收:权限拒绝不得被投影成"被其他写操作占用"。
#[cfg(unix)]
#[test]
fn bridge_write_file_does_not_project_permission_denial_as_contention() {
use std::os::unix::fs::PermissionsExt;
let temporary = tempfile::tempdir().expect("create acl direct write root");
init_local_game_project_at(temporary.path(), "direct-acl", "Direct ACL 测试")
.expect("initialize acl direct write root");
let agent_directory = temporary.path().join(".agent");
let original = fs::metadata(&agent_directory)
.expect("read control directory metadata")
.permissions();
fs::set_permissions(&agent_directory, fs::Permissions::from_mode(0o500))
.expect("drop write permission on the control directory");
let result = bridge_write_file(
temporary.path(),
&json!({ "path": "game/acl-denied.js", "content": "// denied\n" }),
);
fs::set_permissions(&agent_directory, original)
.expect("restore control directory permission");
if result.get("isError").and_then(Value::as_bool) != Some(true) {
// 以 root 运行(或文件系统忽略权限位)时 0o500 不构成拒绝,本用例不成立。
return;
}
let text = result
.pointer("/content/0/text")
.and_then(Value::as_str)
.unwrap_or_default();
assert!(
!text.contains("项目正在被其他写操作占用"),
"a permission denial must not be projected as lock contention: {text}"
);
assert!(!temporary.path().join("game/acl-denied.js").exists());
}
#[test]
fn resource_request_uuid_is_stable_v4_and_domain_separated() {
let operation = direct_resource_request_uuid("turn-1", "operation", "abc");
@@ -1833,16 +1833,28 @@ fn acquire_game_creator_agent_runtime_project_write_lock_within(
max_attempts: usize,
) -> Result<ProjectWriteLock, String> {
let max_attempts = max_attempts.max(1);
let started_at = std::time::Instant::now();
for attempt in 0..max_attempts {
match acquire_project_write_lock(root, command_id) {
let contention = match acquire_project_write_lock(root, command_id) {
Err(error)
if error.starts_with("项目正在被其他写操作占用:")
&& attempt + 1 < max_attempts =>
if error.starts_with(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX) =>
{
std::thread::sleep(AGENT_RUNTIME_PROJECT_WRITE_LOCK_RETRY_INTERVAL);
error
}
result => return result,
};
if attempt + 1 == max_attempts {
// 等待预算耗尽才记一条:争用本身可能重试上千次,逐次记账会淹掉日志。
// 这条记录回答的正是 Issue #318 现场缺的问题——"谁在持锁、是不是自己人"。
app_log!(
"project.write_lock.wait_exhausted commandId={command_id} attempts={} waitedMs={} holder={}",
attempt,
started_at.elapsed().as_millis(),
crate::project::project_write_lock_contention_diagnostic(root)
);
return Err(contention);
}
std::thread::sleep(AGENT_RUNTIME_PROJECT_WRITE_LOCK_RETRY_INTERVAL);
}
unreachable!("project write lock retry loop always returns")
}
@@ -208,6 +208,7 @@ pub(crate) fn project_write_lock_process_start_time_seconds(_process_id: u64) ->
#[derive(Debug, Clone)]
pub(crate) struct ProjectWriteLockSnapshot {
content: Vec<u8>,
command_id: Option<String>,
pid: Option<u64>,
created_at: Option<u64>,
process_started_at: Option<u64>,
@@ -224,12 +225,38 @@ impl ProjectWriteLockSnapshot {
.and_then(serde_json::Value::as_u64)
};
Some(Self {
command_id: payload
.as_ref()
.and_then(|payload| payload.get("commandId"))
.and_then(serde_json::Value::as_str)
.map(str::to_string),
pid: number("pid"),
created_at: number("createdAt"),
process_started_at: number("processStartedAt"),
content,
})
}
/// 持锁方身份的单行描述。Issue #318 的现场只有一句"别人在写",无法回答"到底是谁、
/// 是不是自己人",所以争用错误和等待日志都要带上这几个字段。
/// `ownerIsSelf` 用 `pid` 判定:`true` 是同进程另一条写通道,`false` 才是真外部进程。
pub(crate) fn describe_holder(&self) -> String {
format!(
"commandId={} pid={} createdAt={} ownerIsSelf={}",
self.command_id.as_deref().unwrap_or("unknown"),
self.pid
.map(|pid| pid.to_string())
.unwrap_or_else(|| "unknown".to_string()),
self.created_at
.map(|created_at| created_at.to_string())
.unwrap_or_else(|| "unknown".to_string()),
match self.pid {
Some(pid) if pid == u64::from(std::process::id()) => "true",
Some(_) => "false",
None => "unknown",
},
)
}
}
fn project_write_lock_is_owned_by_current_process(path: &Path) -> bool {
@@ -344,32 +371,120 @@ pub(crate) fn project_write_lock_reclaim(
}
}
fn project_write_lock_open_error_is_contention(error: &std::io::Error) -> bool {
/// `.agent/project.lock` 的争用错误前缀。`project_gates.rs`、`provider_recovery.rs`、
/// `planning_session_v2.rs`、`direct_runtime.rs` 和前端 `App.tsx` 都按这个前缀把争用
/// 识别成"可以等一下"的瞬时状态;文案扩展时要保持前缀逐字不变。
pub(crate) const PROJECT_WRITE_LOCK_CONTENTION_PREFIX: &str = "项目正在被其他写操作占用:";
/// `create_new` 失败到底意味着什么。三类的处置完全不同:争用可以等待,权限拒绝必须
/// 失败关闭,其它 I/O 错误原样上报。混成一句「项目正在被其他写操作占用」会把 ACL
/// 问题、删除挂起和真实跨进程争用一起藏起来(Issue #318 第 3 条)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ProjectWriteLockOpenFailure {
Contention,
Permission,
Other,
}
fn project_write_lock_classify_open_error(
error: &std::io::Error,
lock_path_exists: bool,
) -> ProjectWriteLockOpenFailure {
if error.kind() == std::io::ErrorKind::AlreadyExists {
return true;
return ProjectWriteLockOpenFailure::Contention;
}
#[cfg(windows)]
{
// Windows can report an existing or delete-pending create_new target as
// ACCESS_DENIED instead of ALREADY_EXISTS while another thread drops it.
return error.kind() == std::io::ErrorKind::PermissionDenied
|| matches!(error.raw_os_error(), Some(5 | 32 | 33));
// Windows 会把已存在或处于 delete-pending create_new 目标报成 ACCESS_DENIED
// 而不是 ALREADY_EXISTS。32 / 33 是 sharing violation 与 lock violation,只可能
// 在目标被占用时出现,恒定归争用。
if matches!(error.raw_os_error(), Some(32 | 33)) {
return ProjectWriteLockOpenFailure::Contention;
}
// ACCESS_DENIED(5) 有两种含义,只能靠"目标是否存在"区分:delete-pending 或存在
// 的目标是争用;目标并不存在却仍创建失败,是真正的权限 / ACL 拒绝。
if error.kind() == std::io::ErrorKind::PermissionDenied || error.raw_os_error() == Some(5) {
return if lock_path_exists {
ProjectWriteLockOpenFailure::Contention
} else {
ProjectWriteLockOpenFailure::Permission
};
}
}
#[cfg(not(windows))]
false
{
if error.kind() == std::io::ErrorKind::PermissionDenied {
return ProjectWriteLockOpenFailure::Permission;
}
}
ProjectWriteLockOpenFailure::Other
}
/// 争用错误必须带上持锁方身份。锁文件处于 delete-pending 或尚未写完时读不到身份,
/// 也必须显式表达成"不可读",不能默认成"没有持锁方"。
fn project_write_lock_contention_error(
path: &Path,
snapshot: Option<&ProjectWriteLockSnapshot>,
) -> String {
match snapshot {
Some(snapshot) => format!(
"{PROJECT_WRITE_LOCK_CONTENTION_PREFIX}{}(持锁方 {}",
path.display(),
snapshot.describe_holder()
),
None => format!(
"{PROJECT_WRITE_LOCK_CONTENTION_PREFIX}{}(持锁方身份不可读:锁文件可能处于删除挂起或尚未写完)",
path.display()
),
}
}
fn project_write_lock_permission_error(path: &Path, error: &std::io::Error) -> String {
// 文案刻意不含争用前缀:`..._with_wait`、`provider_recovery.rs` 和前端都按前缀把
// 错误当成"等一下就好"的瞬时状态,权限拒绝必须失败关闭。
format!(
"项目写锁路径权限被拒绝,不是写锁争用(请检查项目目录与 .agent 目录的 ACL):{}: {error}",
path.display()
)
}
/// 等待预算耗尽时写进 App 日志的持锁方快照。
pub(crate) fn project_write_lock_contention_diagnostic(root: &Path) -> String {
let Ok(path) = resolve_project_write_lock_path(root) else {
return "持锁方身份不可解析".to_string();
};
match ProjectWriteLockSnapshot::read(&path) {
Some(snapshot) => snapshot.describe_holder(),
None => "持锁方身份不可读(锁文件可能处于删除挂起或尚未写完)".to_string(),
}
}
#[cfg(all(test, windows))]
#[test]
fn project_write_lock_treats_windows_target_races_as_contention() {
for code in [5, 32, 33] {
assert!(
project_write_lock_open_error_is_contention(&std::io::Error::from_raw_os_error(code)),
"Windows project lock error {code} must enter the bounded contention wait"
assert_eq!(
project_write_lock_classify_open_error(
&std::io::Error::from_raw_os_error(code),
true
),
ProjectWriteLockOpenFailure::Contention,
"Windows project lock error {code} with an existing target must enter the bounded contention wait"
);
}
}
#[cfg(windows)]
#[test]
fn project_write_lock_reports_acl_denial_without_an_existing_target() {
// delete-pending 与真实 ACL 拒绝在 Windows 上同为 ACCESS_DENIED(5);目标不存在时
// 必须落到权限类,否则 ACL 问题会被投影成"别人在写"。
assert_eq!(
project_write_lock_classify_open_error(&std::io::Error::from_raw_os_error(5), false),
ProjectWriteLockOpenFailure::Permission
);
}
#[cfg(all(test, windows))]
#[test]
fn project_write_lock_hardens_space_containing_path_in_process() {
@@ -473,10 +588,29 @@ pub(crate) fn acquire_project_write_lock(
bypassed_same_process: false,
});
}
Err(error) if project_write_lock_open_error_is_contention(&error) => {
Err(error) => {
let failure = project_write_lock_classify_open_error(&error, path.exists());
if failure == ProjectWriteLockOpenFailure::Permission {
// 权限类错误不会重试,所以在这里记录:它必须能在 App 日志里
// 和"别人正在写"区分开。
app_log!(
"project.write_lock.permission_denied commandId={command_id} path={} osError={:?}",
path.display(),
error.raw_os_error()
);
return Err(project_write_lock_permission_error(&path, &error));
}
if failure == ProjectWriteLockOpenFailure::Other {
return Err(format!("创建项目写锁失败:{}: {error}", path.display()));
}
if !retried_after_reclaim {
if let Some(snapshot) = project_write_lock_reclaimable_snapshot(&path) {
if project_write_lock_reclaim(&path, &snapshot)? {
app_log!(
"project.write_lock.reclaim_stale commandId={command_id} path={} holder={}",
path.display(),
snapshot.describe_holder()
);
retried_after_reclaim = true;
continue;
}
@@ -496,10 +630,12 @@ pub(crate) fn acquire_project_write_lock(
bypassed_same_process: true,
});
}
return Err(format!("项目正在被其他写操作占用:{}", path.display()));
}
Err(error) => {
return Err(format!("创建项目写锁失败:{}: {error}", path.display()));
// 争用不在零等待入口里记日志:有界等待会把这个函数调用上千次,
// 每次记一行会淹掉日志。等待方在预算耗尽时记一条带等待时长的记录。
return Err(project_write_lock_contention_error(
&path,
ProjectWriteLockSnapshot::read(&path).as_ref(),
));
}
}
}
@@ -356,3 +356,84 @@ fn project_write_lock_decision_keeps_lock_when_mtime_is_unknown() {
);
fs::remove_dir_all(root).ok();
}
/// Issue #318 取证缺口:争用错误必须点名持锁方,现场才能回答"谁在持锁、是不是自己人"。
/// 另一个活进程持锁时必须报成争用、带出身份,并且不能把它的锁当成残留回收掉。
#[cfg(any(windows, target_os = "linux"))]
#[test]
fn project_write_lock_contention_names_a_live_external_holder() {
let root = unique_project_path();
fs::create_dir_all(root.join(".agent")).expect("创建 .agent 目录");
let holder = spawn_unrelated_live_process();
let holder_pid = holder.id();
write_project_lock_fixture(
&root,
&serde_json::to_vec_pretty(&serde_json::json!({
"commandId": "external-editor-writer",
"pid": holder_pid,
"createdAt": unix_timestamp(),
"nonce": 7,
}))
.expect("序列化外部持有者锁 fixture"),
);
let held_content = fs::read(root.join(PROJECT_LOCK_RELATIVE_PATH)).expect("读回持有者锁");
let error =
acquire_project_write_lock(&root, "file.write").expect_err("活的外部进程持锁时必须报争用");
assert!(
error.starts_with("项目正在被其他写操作占用:"),
"争用必须保持共享前缀:{error}"
);
assert!(
error.contains("commandId=external-editor-writer"),
"错误必须点名持锁命令:{error}"
);
assert!(
error.contains(&format!("pid={holder_pid}")),
"错误必须点名持锁进程:{error}"
);
assert!(
error.contains("ownerIsSelf=false"),
"错误必须说明持锁方不是本进程:{error}"
);
assert_eq!(
fs::read(root.join(PROJECT_LOCK_RELATIVE_PATH)).expect("复查持有者锁"),
held_content,
"活外部持有者的锁文件不得被回收或改写"
);
assert!(
project_write_lock_contention_diagnostic(&root).contains(&format!("pid={holder_pid}")),
"等待日志必须带同一份持锁方身份"
);
stop_unrelated_live_process(holder);
fs::remove_dir_all(root).ok();
}
/// Issue #318 第 3 条:权限拒绝不能再投影成"被其他写操作占用"。
/// 文案刻意不含争用前缀,有界等待和前端才不会把它当成"等一下就好"的瞬时状态。
#[cfg(unix)]
#[test]
fn project_write_lock_does_not_project_permission_denial_as_contention() {
use std::os::unix::fs::PermissionsExt;
let root = unique_project_path();
let agent_directory = root.join(".agent");
fs::create_dir_all(&agent_directory).expect("创建 .agent 目录");
let original = fs::metadata(&agent_directory)
.expect("读取控制目录元数据")
.permissions();
fs::set_permissions(&agent_directory, fs::Permissions::from_mode(0o500))
.expect("去掉控制目录写权限");
let error =
acquire_project_write_lock(&root, "file.write").expect_err("只读控制目录必须挡住取锁");
fs::set_permissions(&agent_directory, original).expect("恢复控制目录权限");
assert!(
!error.starts_with("项目正在被其他写操作占用:"),
"权限拒绝不得投影成写锁争用:{error}"
);
fs::remove_dir_all(root).ok();
}
@@ -8197,3 +8197,12 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 边界:`agent-runner.lock` / `agent-runner.gui-owner.lock` 是 OS 独占句柄锁,进程退出即释放,残留文件不阻塞下次启动;不要把它们当成项目写锁的同类残留处理。
- 边界:锁文件里的 PID 若超出平台进程号空间(Unix `pid_t` 是有符号 32 位、Windows 是 32 位,均恒大于 0),它不可能属于任何活进程,按“持有者不存在”直接回收,不再落回 600 秒保守分支。
- 验证:`project_lock_recovery` 11 条与 `diagnostic_log` 7 条定向测试通过,真实二进制双实例复现“第二个实例写 `startup.runner.owner-lock.failed` 并弹出可见提示”。
## 2026-09-10 Direct 写通道纳入统一项目锁等待窗口并补齐持锁方可诊断
- 背景:Issue #318`agc_write_file` 是用户直接触发、失败即整轮无法落盘的项目写入通道,却用零等待取锁,任何重叠都在 24-42ms 内被投影成“项目正在被其他写操作占用”;同一形状已在 2026-07-22 由 `file.write / file.patch / file.delete` 用有界等待修过,本项目技术方案的 2026-08-13 一节也已规定这类争用结果“统一投影为争用并进入既有有界等待”。现场取证还缺 `commandId / pid / createdAt / ownerIsSelf`,无法回答“谁在持锁”,加上 `create_new` 把 ACL 拒绝、delete-pending 和真实跨进程争用压成同一句话,排障被引向“残留锁”。
- 决策:① Direct 写路径改用 `acquire_game_creator_agent_runtime_project_write_lock_with_wait`,与其它写入口同语义,同一轮并行写按同一把锁串行。② 争用错误前缀逐字不变并追加持锁方身份;`create_new` 失败拆成争用(进入有界等待)/ 权限拒绝(失败关闭,文案不含争用前缀)/ 其它三类;Windows 上 delete-pending 与 ACL 拒绝同为 `ACCESS_DENIED(5)`,按“目标是否存在”区分,`sharing violation(32)``lock violation(33)` 恒定归争用。③ 等待预算耗尽时按 `project.write_lock.wait_exhausted` 记录持锁方快照与等待时长,权限拒绝按 `project.write_lock.permission_denied` 记录。
- 复用既有实现:回收判据沿用 2026-09-09 的 `ProjectWriteLockSnapshot` + `project_write_lock_reclaim_decision` + 字节 CAS 删除,不新增第二套回收机制;本次只给快照补 `commandId``describe_holder()`,供错误文案和日志使用。
- 不做什么:不放宽 `.agent/project.lock` 的项目级串行化语义,不引入可重入项目锁,不改“同一调用链禁止二次获取 `.agent/project.lock`”的既有约定,不改 AGC 多进程拓扑,不改 `pendingOperations` 语义,也不改“活持有者始终不回收”的既有判据。其它仍用零等待取锁的入口(`command.exec / project.verify / memory / conversation / task / checkpoint / 预览 / UI 编辑器 / 资源编辑器 / Tauri 命令`)不在本次范围。
- 验证方式:`project_lock_recovery` 追加持锁方身份与权限拒绝两条;`direct_tool_bridge` 追加同进程重叠写等待、同轮并行写、ACL 拒绝不投影成争用三条。
- 关联文档:`docs/project-memory/shared-memory/pitfalls.md``docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、Issue #318
@@ -5059,3 +5059,12 @@
- 处理:payload 只解析一次并连同字节一起快照;删除前重新核对字节,只有内容仍是判定时的内容才 unlink;文件已消失或被替换时返回 false 并重试 `create_new`,不报错。
- 补充:`project_write_lock_file_modified_seconds` 读不到 mtime 时不要返回 `0`——纪元 0 会被算成极大年龄,把保守判定反转成“立刻回收”,甚至把活持有者当 PID 复用抢走;要用 `Option` 区分“mtime 未知”和“mtime 等于纪元 0”。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs`
## 2026-09-10 Direct 写通道零等待取锁把毫秒级竞争放大成整轮阻断
- **现象**:AGC 新建项目后第一轮 Direct 对话里,唯一的项目写入通道 `agc_write_file` 每次都返回 `项目正在被其他写操作占用:<项目根>\.agent\project.lock`;同一轮 15 次写入全部 `status=failed``durationMs` 只有 24-42ms,而只读工具(`agc_list_project_files``agc_list_registered_assets``client.session.info`)全部正常,整轮无法写入任何项目文件。
- **原因**`.agent/project.lock``create_new` 存在性锁,Direct 通道却调零等待的 `acquire_project_write_lock`,与 App 自身其它写通道(美术 lane、revision、conversation、预览等)撞车就直接判死;而 `file.write / file.patch / file.delete` 等入口走的是约 10 秒有界等待。**失败耗时本身就是判据**:几十毫秒说明这个入口根本没等,同等争用在其它通道会被等待窗口吸收。此外错误文案不带 `commandId / pid / createdAt / ownerIsSelf`,又把 ACL 拒绝、delete-pending 和真实跨进程争用压成同一句话,现场很容易被误判成“残留锁”。
- **处理**:Direct 写路径改用统一的有界等待;`create_new` 失败按争用 / 权限 / 其它分三类并给不同文案;争用错误与等待日志都带持锁方身份,`ownerIsSelf` 区分“自己人”和“别人”。
- **排查顺序**:① 先看失败耗时——几十毫秒说明该入口没等,是等待窗口缺失,不是锁没释放。② 看错误里的 `ownerIsSelf``true` 是同进程另一条写通道,`false` 才是真外部进程。③ **锁文件在失败后通常已被 Drop 删掉,现场缺文件不否定争用**;同理 `agc_list_registered_assets``pendingOperations: []` 只表示没有在跑的付费生成,与项目写锁无关,不构成“锁没有持有者”的证据。④ `.agent/.manifest.json.lock` 是 manifest 的持久 OS 文件锁(Windows 不共享写句柄 / Unix `flock`),0 字节长期存在是设计如此,不是残留锁,也不要用项目写锁的回收判据去处理它。
- **验证**:Rust 定向覆盖同进程重叠写等待、同轮并行写、活外部进程持锁带身份、以及权限拒绝不投影成争用。
- **关联**`apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs``apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs`
@@ -1337,3 +1337,13 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过
- 回收判据与删除必须基于同一次读到的锁文件快照:payload 只解析一次,删除前重新核对字节,只有内容仍是判定时的内容才 unlink;文件已消失或被替换时重试 `create_new`,不把并发回收当成错误。
- 启动诊断日志改为 `StartupLogSlot`:优先用已经生效的配置目录(含 `--config-dir`),否则退到平台配置根(Windows APPDATA、macOS Application Support、其它平台 `XDG_CONFIG_HOME` / `~/.config`),成功后再切换到真实配置目录。`startup.*.failed``show_startup_error_dialog` 不再是死分支;日志路径未知时同样给出用户可见提示。Windows 启动失败恢复系统消息框并附诊断日志路径,其它平台写 stderr,同一进程只提示一次。
- 边界与验证:残留的 `agent-runner.lock` / `agent-runner.gui-owner.lock` 是 OS 独占句柄锁,进程退出即释放,文件本身不阻塞下次启动;真正阻塞启动的是仍有活进程持锁。验证覆盖 `project_lock_recovery` 11 条(死 PID、非法进程号、空锁宽限、PID 复用时间推断、PID 复用身份不一致、身份一致不抢锁、旧格式活持有者不抢锁、新鲜空锁不抢锁、存活未知保守回收、mtime 未知保守回收、并发替换或已消失时不删除)、`diagnostic_log` 7 条,以及真实二进制双实例:第二个实例写入 `startup.runner.owner-lock.failed` 并弹出可见提示。
## 2026-09-10 Direct 写通道项目锁等待、持锁方可诊断与权限分类
- `agc_write_file` 是用户直接触发、失败即整轮无法落盘的项目写入通道,原先却用零等待 `acquire_project_write_lock`:任何重叠都在 24-42ms 内被判成“项目正在被其他写操作占用”,而 `file.write / file.patch / file.delete` 等入口用的是约 10 秒有界等待。现统一为 `acquire_game_creator_agent_runtime_project_write_lock_with_wait`:短暂重叠排队等成功,只有预算耗尽才报出带持锁方身份的错误;同一轮并行写多个文件按同一把锁串行。这是 2026-07-22 同一形状修复在 Direct 通道上的补齐,与 2026-08-13 一节“这些结果统一投影为争用并进入既有有界等待”的口径一致。**失败耗时是判据**:几十毫秒说明该入口没等,不是锁没释放。
- 争用错误必须带持锁方身份才可行动:`项目正在被其他写操作占用:<锁路径>(持锁方 commandId=<命令> pid=<进程> createdAt=<创建时间> ownerIsSelf=<是否本进程>`。锁文件处于 delete-pending 或尚未写完时读不到身份,也必须显式表达成“不可读”,不得默认成“没有持锁方”。前缀逐字不变:`project_gates.rs``provider_recovery.rs``planning_session_v2.rs``direct_runtime.rs` 和前端 `App.tsx` 都按它把争用识别成可等待的瞬时状态。
- `create_new` 的失败必须分三类处置,不能再共用一句文案:目标已存在(含 Windows delete-pending)是争用,进入有界等待;锁文件并不存在却仍创建失败是权限 / ACL 拒绝,失败关闭且文案不得含争用前缀;其它 I/O 错误原样上报。Windows 上 delete-pending 与真实 ACL 拒绝同为 `ACCESS_DENIED(5)`,只能靠“目标是否存在”区分;`sharing violation(32)``lock violation(33)` 只可能在目标被占用时出现,恒定归争用。归类判据以纯函数形式保留,便于不依赖 ACL 环境直接断言。
- 复用 2026-09-09 的回收机制,不新增第二套:`ProjectWriteLockSnapshot``commandId``describe_holder()`,争用错误、`project.write_lock.reclaim_stale``project.write_lock.wait_exhausted` 三处共用同一份身份描述。“活持有者始终不回收”的判据不变。
- 等待预算耗尽时按 `project.write_lock.wait_exhausted` 记录 `commandId`、尝试次数、等待毫秒数与持锁方身份,权限拒绝按 `project.write_lock.permission_denied` 记录;争用不在零等待入口里逐次记账,避免有界等待的上千次重试淹没日志。这条日志正是 Issue #318 现场缺的“谁在持锁、是不是自己人”。
- 定向验收覆盖:同进程重叠写等待后成功、同一轮并行写多个文件、真实存活外部进程持锁(错误带 `ownerIsSelf=false` 且锁文件不被回收)、ACL 拒绝不投影成争用。对应 Rust `project_lock_recovery``direct_tool_bridge` 定向测试。
- 仍待收口(后续事项):其余仍用零等待取锁的入口(`command.exec / project.verify / memory / conversation / task / checkpoint / 预览 / UI 编辑器 / 资源编辑器 / Tauri 命令`)本批不改,遇到同类争用时仍会立刻失败。