修复 AGC Direct 写通道项目锁零等待与持锁方不可诊断
- 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:
@@ -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 命令`)本批不改,遇到同类争用时仍会立刻失败。
|
||||
|
||||
Reference in New Issue
Block a user