From 06f738dc996fba48d675e3325fd0de5492db5b78 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 10 Sep 2026 20:12:28 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B0=86=E9=A1=B9=E7=9B=AE=E5=86=99=E9=94=81?= =?UTF-8?q?=E4=BB=8E=20project/filesystem.rs=20=E7=BA=AF=E6=90=AC=E7=A7=BB?= =?UTF-8?q?=E5=88=B0=20project/write=5Flock.rs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新建 project/write_lock.rs:取锁、等待分类、持锁方诊断、残留回收与 4 条锁用例整体搬移,逻辑不变 - project/filesystem.rs 只保留项目文件 IO(1533 → 680 行),锁相关常量、结构、进程判据与用例全部移出 - project.rs 注册 mod write_lock 并 pub(crate) use write_lock::*,crate::project:: 与 crate:: 既有路径不变 - windows_metadata_is_reparse_point 提为 pub(crate),供 write_lock 复用同一条 reparse point 判据 - agent_db.rs 与 checkpoint.rs 的 PROJECT_FILE_FLAG_OPEN_REPARSE_POINT 导入路径改为 super::write_lock - 同步修正技术方案、Fast GDD 技术方案、decision-log、pitfalls 中指向锁实现的文件路径,并把“拆锁”从后续事项改为已完成 --- .../src-tauri/src/project.rs | 2 + .../src-tauri/src/project/agent_db.rs | 2 +- .../src-tauri/src/project/checkpoint.rs | 2 +- .../src-tauri/src/project/filesystem.rs | 859 +----------------- .../src-tauri/src/project/write_lock.rs | 856 +++++++++++++++++ .../shared-memory/decision-log.md | 2 +- docs/project-memory/shared-memory/pitfalls.md | 6 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- ...方案】立项策划Agent(Fast GDD)-2026-08-10.md | 2 +- 9 files changed, 869 insertions(+), 864 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index 4ffe94674..003d4464b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -16,6 +16,7 @@ mod resource_dependency_graph; mod resource_editor; mod resource_layout; mod verification; +mod write_lock; pub(crate) use agent_db::*; pub(crate) use asset_canvas::*; @@ -30,3 +31,4 @@ pub(crate) use resource_dependency_graph::*; pub(crate) use resource_editor::*; pub(crate) use resource_layout::*; pub(crate) use verification::*; +pub(crate) use write_lock::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs index ca30e8bf4..08691c487 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs @@ -2,7 +2,7 @@ use super::*; use std::collections::BTreeSet; #[cfg(windows)] -use super::filesystem::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT; +use super::write_lock::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT; const AGENT_DB_MAX_RECORD_BYTES: usize = 1024 * 1024; const AGENT_DB_ACTION_RECEIPT_RECORD_TYPE: &str = "agent.runtime.action_receipt"; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint.rs b/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint.rs index 9c99aaede..380cc2836 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint.rs @@ -3,7 +3,7 @@ use super::*; use super::filesystem::validate_portable_project_path_component; #[cfg(windows)] -use super::filesystem::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT; +use super::write_lock::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT; #[cfg(windows)] fn windows_regular_file_handle_identity(file: &File, label: &str) -> Result<(u32, u64), String> { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs index 876720523..6f067628b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs @@ -1,860 +1,5 @@ use super::*; -#[cfg(windows)] -pub(crate) const PROJECT_FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; - -static PROJECT_WRITE_LOCK_NONCE: std::sync::atomic::AtomicU64 = - std::sync::atomic::AtomicU64::new(1); -const PROJECT_WRITE_LOCK_STALE_AFTER_SECONDS: u64 = 600; -/// 崩溃可能停在 `create_new` 成功、payload 落盘之前,此时锁文件没有任何持有者 -/// 信息。写入方正常情况下在毫秒级完成落盘,所以只需要很短的宽限期就能确认它 -/// 已经放弃,而不是让项目在整整 10 分钟里都不可写。 -const PROJECT_WRITE_LOCK_UNWRITTEN_GRACE_SECONDS: u64 = 30; -/// 进程启动时间与锁 `createdAt` 之间的允许偏差(秒),用来抵消时间戳精度差异。 -const PROJECT_WRITE_LOCK_PID_REUSE_TOLERANCE_SECONDS: u64 = 5; -const PROJECT_WRITE_LOCK_MAX_BYTES: u64 = 4 * 1024; - -#[derive(Debug)] -pub(crate) struct ProjectWriteLock { - path: PathBuf, - content: String, - /// In the free-form autonomous lane a single Runtime process may have - /// several specialist actions in flight at once. A file lock is still - /// useful across processes, but making same-process contenders fail turns - /// ordinary parallel work into a dead run (and can deadlock nested tool - /// calls). Such a contender receives an in-process/advisory guard instead - /// of deleting the real holder's lock on drop. - bypassed_same_process: bool, -} - -impl ProjectWriteLock { - pub(crate) fn guards_project_root(&self, root: &Path) -> Result { - let expected_path = resolve_local_project_path(root, PROJECT_WRITE_LOCK_PATH)?; - if self.bypassed_same_process { - // The relaxed guard deliberately has no ownership of the durable - // `.agent/project.lock` file. It still binds the observation to - // the validated project root so callers cannot use a guard from a - // different project. - return Ok(self.path == expected_path); - } - Ok(self.path == expected_path - && fs::read_to_string(&self.path).is_ok_and(|content| content == self.content)) - } -} - -impl Drop for ProjectWriteLock { - fn drop(&mut self) { - if self.bypassed_same_process { - return; - } - if fs::read_to_string(&self.path).is_ok_and(|content| content == self.content) { - let _ = fs::remove_file(&self.path); - } - } -} - -#[cfg(unix)] -fn project_write_lock_process_is_alive(process_id: u64) -> Option { - // Unix 的 pid_t 是有符号 32 位且恒大于 0,超出该范围的取值不可能是本机 - // 任何进程,说明锁文件里的 PID 已经损坏,可以直接判定持有者不存在。 - let Some(process_id) = i32::try_from(process_id).ok().filter(|value| *value > 0) else { - return Some(false); - }; - let result = unsafe { libc::kill(process_id, 0) }; - if result == 0 { - return Some(true); - } - match std::io::Error::last_os_error().raw_os_error() { - Some(libc::ESRCH) => Some(false), - Some(libc::EPERM) => Some(true), - _ => None, - } -} - -#[cfg(windows)] -fn project_write_lock_process_is_alive(process_id: u64) -> Option { - use std::ffi::c_void; - - #[link(name = "kernel32")] - unsafe extern "system" { - fn OpenProcess(access: u32, inherit_handle: i32, process_id: u32) -> *mut c_void; - fn GetExitCodeProcess(process: *mut c_void, exit_code: *mut u32) -> i32; - fn CloseHandle(handle: *mut c_void) -> i32; - } - - // Windows 进程号是 32 位且恒大于 0,超出该范围的取值不可能是本机任何 - // 进程,说明锁文件里的 PID 已经损坏,可以直接判定持有者不存在。 - let Some(process_id) = u32::try_from(process_id).ok().filter(|value| *value > 0) else { - return Some(false); - }; - const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000; - const STILL_ACTIVE: u32 = 259; - // SAFETY: OpenProcess returns an owned kernel handle or null; it is - // closed below. We only request the query permission needed here. - let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, process_id) }; - if process.is_null() { - // ERROR_INVALID_PARAMETER means the process no longer exists. For - // access-denied/other failures we cannot prove liveness, so keep the - // conservative unknown result and let the normal bounded wait decide. - return match std::io::Error::last_os_error().raw_os_error() { - Some(87) => Some(false), - _ => None, - }; - } - let mut exit_code = 0_u32; - // SAFETY: `exit_code` is a writable scalar and `process` is a live handle. - let result = unsafe { GetExitCodeProcess(process, &mut exit_code) }; - // SAFETY: `process` is an owned handle returned by OpenProcess. - unsafe { CloseHandle(process) }; - if result == 0 { - return None; - } - Some(exit_code == STILL_ACTIVE) -} - -#[cfg(not(any(unix, windows)))] -fn project_write_lock_process_is_alive(_process_id: u64) -> Option { - None -} - -/// 读取进程的启动时间(Unix 秒)。用来区分“锁记录里的 PID 仍然属于原来的持有 -/// 者”和“PID 已经被系统复用给另一个进程”。无法判定的平台返回 `None`,此时 -/// 保持原有的保守回收策略。 -#[cfg(windows)] -pub(crate) fn project_write_lock_process_start_time_seconds(process_id: u64) -> Option { - use std::ffi::c_void; - - #[repr(C)] - struct FileTime { - low_date_time: u32, - high_date_time: u32, - } - - #[link(name = "kernel32")] - unsafe extern "system" { - fn OpenProcess(access: u32, inherit_handle: i32, process_id: u32) -> *mut c_void; - fn GetProcessTimes( - process: *mut c_void, - creation_time: *mut FileTime, - exit_time: *mut FileTime, - kernel_time: *mut FileTime, - user_time: *mut FileTime, - ) -> i32; - fn CloseHandle(handle: *mut c_void) -> i32; - } - - const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000; - /// Windows FILETIME 起点(1601-01-01)到 Unix 纪元之间的 100 纳秒数。 - const FILETIME_UNIX_EPOCH_OFFSET: u64 = 116_444_736_000_000_000; - let process_id = u32::try_from(process_id).ok().filter(|value| *value > 0)?; - // SAFETY: OpenProcess returns an owned kernel handle or null; it is closed below. - let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, process_id) }; - if process.is_null() { - return None; - } - // SAFETY: every FileTime is plain data filled by GetProcessTimes. - let mut creation = unsafe { std::mem::zeroed::() }; - let mut exit = unsafe { std::mem::zeroed::() }; - let mut kernel = unsafe { std::mem::zeroed::() }; - let mut user = unsafe { std::mem::zeroed::() }; - // SAFETY: `process` is a live handle and all four pointers are writable scalars. - let result = - unsafe { GetProcessTimes(process, &mut creation, &mut exit, &mut kernel, &mut user) }; - // SAFETY: `process` is an owned handle returned by OpenProcess. - unsafe { CloseHandle(process) }; - if result == 0 { - return None; - } - let file_time = (u64::from(creation.high_date_time) << 32) | u64::from(creation.low_date_time); - file_time - .checked_sub(FILETIME_UNIX_EPOCH_OFFSET) - .map(|unix_100ns| unix_100ns / 10_000_000) -} - -#[cfg(target_os = "linux")] -pub(crate) fn project_write_lock_process_start_time_seconds(process_id: u64) -> Option { - let process_id = u32::try_from(process_id).ok().filter(|value| *value > 0)?; - // SAFETY: sysconf has no memory safety preconditions and returns -1 on failure. - let clock_ticks = unsafe { libc::sysconf(libc::_SC_CLK_TCK) }; - if clock_ticks <= 0 { - return None; - } - let stat = fs::read_to_string(format!("/proc/{process_id}/stat")).ok()?; - let start_ticks = stat - .rsplit_once(") ")? - .1 - .split_whitespace() - .nth(19)? - .parse::() - .ok()?; - let boot_time = fs::read_to_string("/proc/stat") - .ok()? - .lines() - .find_map(|line| line.strip_prefix("btime "))? - .trim() - .parse::() - .ok()?; - Some(boot_time + start_ticks / clock_ticks as u64) -} - -#[cfg(not(any(windows, target_os = "linux")))] -pub(crate) fn project_write_lock_process_start_time_seconds(_process_id: u64) -> Option { - None -} - -/// 一次读到的锁文件字节与解析结果。回收判据和随后的删除必须基于同一份快照: -/// 分别重读 `pid` / `createdAt` / `processStartedAt` 会把旧 inode 的持有者信息 -/// 和新 inode 的启动身份拼在一起,也会让判定与删除命中不同的文件。 -#[derive(Debug, Clone)] -pub(crate) struct ProjectWriteLockSnapshot { - content: Vec, - command_id: Option, - pid: Option, - created_at: Option, - process_started_at: Option, -} - -impl ProjectWriteLockSnapshot { - pub(crate) fn read(path: &Path) -> Option { - let content = fs::read(path).ok()?; - let payload = serde_json::from_slice::(&content).ok(); - let number = |key: &str| { - payload - .as_ref() - .and_then(|payload| payload.get(key)) - .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 { - ProjectWriteLockSnapshot::read(path).and_then(|snapshot| snapshot.pid) - == Some(u64::from(std::process::id())) -} - -/// 读取锁文件 mtime 的 Unix 秒数;读不到时返回 `None`。调用方必须把“mtime 未知” -/// 和“mtime 等于纪元 0”区分开:后者会被算成极大的年龄,反而把保守判定反转成 -/// “立刻回收”,甚至把活持有者的锁当成 PID 复用抢走。 -fn project_write_lock_file_modified_seconds(metadata: &fs::Metadata) -> Option { - metadata - .modified() - .ok() - .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok()) - .map(|duration| duration.as_secs()) -} - -/// 锁文件年龄(秒)。`createdAt` 与 mtime 都无法确定时返回 `None`:未知年龄只能 -/// 按“不回收”处理,不能退化成 0 或极大值。 -fn project_write_lock_age_seconds( - snapshot: &ProjectWriteLockSnapshot, - modified_at: Option, - now: u64, -) -> Option { - if let Some(created_at) = snapshot.created_at { - return Some(now.saturating_sub(created_at)); - } - modified_at.map(|modified_at| now.saturating_sub(modified_at)) -} - -/// 回收判据。进程存活与启动时间查询作为参数传入,便于用确定性用例覆盖真实进程 -/// 难以构造的分支(存活状态无法判定、mtime 不可读)。 -pub(crate) fn project_write_lock_reclaim_decision( - snapshot: &ProjectWriteLockSnapshot, - modified_at: Option, - now: u64, - process_is_alive: impl Fn(u64) -> Option, - process_started_at: impl Fn(u64) -> Option, -) -> bool { - let Some(owner_pid) = snapshot.pid else { - // 没有可用的持有者信息(空锁、坏锁、无数字 pid 的锁):只按短宽限期回收。 - return project_write_lock_age_seconds(snapshot, modified_at, now) - .is_some_and(|age| age > PROJECT_WRITE_LOCK_UNWRITTEN_GRACE_SECONDS); - }; - match process_is_alive(owner_pid) { - Some(false) => true, - Some(true) => { - // PID 会被系统复用,必须确认当前同名进程就是当时的持有者。 - match (snapshot.process_started_at, process_started_at(owner_pid)) { - // 新锁自带启动身份:同一进程的身份恒定,不一致即为 PID 复用。 - (Some(stored), Some(actual)) => stored != actual, - // 旧锁没有启动身份,只能用“启动时间晚于锁创建时间”推断 PID 复用; - // 锁创建时间未知时不做推断,避免把“未知”当成“复用”抢走活持有者。 - (None, Some(actual)) => { - let Some(lock_created_at) = snapshot.created_at.or(modified_at) else { - return false; - }; - actual - > lock_created_at - .saturating_add(PROJECT_WRITE_LOCK_PID_REUSE_TOLERANCE_SECONDS) - } - _ => false, - } - } - // 无法判定持有者是否存活时保持保守策略:只有明显过期才回收。 - None => project_write_lock_age_seconds(snapshot, modified_at, now) - .is_some_and(|age| age > PROJECT_WRITE_LOCK_STALE_AFTER_SECONDS), - } -} - -/// 判定残留锁可回收时返回判定所依据的快照,否则返回 `None`。 -fn project_write_lock_reclaimable_snapshot(path: &Path) -> Option { - let metadata = fs::symlink_metadata(path).ok()?; - if metadata.file_type().is_symlink() - || windows_metadata_is_reparse_point(&metadata) - || !metadata.is_file() - || metadata.len() > PROJECT_WRITE_LOCK_MAX_BYTES - { - return None; - } - let snapshot = ProjectWriteLockSnapshot::read(path)?; - project_write_lock_reclaim_decision( - &snapshot, - project_write_lock_file_modified_seconds(&metadata), - unix_timestamp(), - project_write_lock_process_is_alive, - project_write_lock_process_start_time_seconds, - ) - .then_some(snapshot) -} - -/// 删除判定为残留的锁文件。判定只是快照观察,删除前必须重新核对字节,确认删掉的 -/// 仍是判定时的那个文件:并发方可能已经回收并装上了自己的活锁。文件已经消失或 -/// 已被替换时返回 `false`,让调用方重试 `create_new` 重新竞争,而不是报错。 -pub(crate) fn project_write_lock_reclaim( - path: &Path, - snapshot: &ProjectWriteLockSnapshot, -) -> Result { - match fs::read(path) { - Ok(content) if content == snapshot.content => {} - Ok(_) => return Ok(false), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), - Err(error) => { - return Err(format!("读取失效项目写锁失败:{}: {error}", path.display())); - } - } - match fs::remove_file(path) { - Ok(()) => Ok(true), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), - Err(error) => Err(format!("清理失效项目写锁失败:{}: {error}", path.display())), - } -} - -/// `.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 = "项目正在被其他写操作占用:"; - -/// 锁分类判据必须能被两个平台覆盖,所以平台由参数传入而不是藏在 `#[cfg]` 后面: -/// CI 只有 Linux runner,`#[cfg(windows)]` 的用例在 CI 里一次都不会跑,而 Windows 特有的 -/// `ACCESS_DENIED(5)` / sharing violation(32) / lock violation(33) 分支恰恰是最危险的一段。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ProjectWriteLockPlatform { - Windows, - Unix, -} - -pub(crate) const PROJECT_WRITE_LOCK_PLATFORM: ProjectWriteLockPlatform = if cfg!(windows) { - ProjectWriteLockPlatform::Windows -} else { - ProjectWriteLockPlatform::Unix -}; - -/// 一次 `create_new` 失败在**等待契约**上的归类。三类的处置完全不同:可重试、权限拒绝 -/// 必须失败关闭、其它 I/O 错误原样上报。混成一句「项目正在被其他写操作占用」会把 ACL -/// 问题、删除挂起和真实跨进程争用一起藏起来(Issue #318 第 3 条)。 -/// -/// 判据只能是错误码本身。**不要用 `path.exists()` 这种一次 metadata 观察决定"要不要重试"**: -/// 目标被删除时目录项先消失、删除挂起随后才结束,`create_new` 会在这个拆链窗口里返回 -/// `ACCESS_DENIED(5)`,而 `exists()` 往往已经报 false。本机实测 60000 次建锁 / 删锁竞争里 -/// 有 396-538 例命中"5 + 目标不可见";按"目标不存在"判成权限拒绝,等待层就会立刻失败关闭 -/// ——正是 Issue #318 要消灭的"毫秒级直接失败",只是换成了更误导的 ACL 文案。 -/// 终态投影放在等待预算耗尽之后做,见 `ProjectWriteLockFailure::exhausted_projection`。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ProjectWriteLockOpenFailure { - /// 目标被占用、删除挂起或正处于删除拆链窗口:短暂重叠,可重试。 - Retryable, - /// 明确不是争用:权限 / ACL 拒绝,失败关闭。 - Permission, - /// 其它 I/O 错误,原样上报。 - Other, -} - -fn project_write_lock_open_failure_for( - platform: ProjectWriteLockPlatform, - error: &std::io::Error, -) -> ProjectWriteLockOpenFailure { - if error.kind() == std::io::ErrorKind::AlreadyExists { - return ProjectWriteLockOpenFailure::Retryable; - } - match platform { - ProjectWriteLockPlatform::Windows => { - // 32 / 33 是 sharing / lock violation,只可能在目标被占用时出现,恒定可重试。 - // ACCESS_DENIED(5) 既可能是 delete-pending / 删除拆链窗口,也可能是真实 ACL - // 拒绝,错误码上不可区分,因此同样先按可重试处理,由等待方在预算耗尽后再判定终态。 - if matches!(error.raw_os_error(), Some(5 | 32 | 33)) - || error.kind() == std::io::ErrorKind::PermissionDenied - { - return ProjectWriteLockOpenFailure::Retryable; - } - } - ProjectWriteLockPlatform::Unix => { - // Unix 没有删除挂起:目标存在必然先命中 AlreadyExists,EACCES 就是权限拒绝, - // 可以立刻判定,不必让调用方白等一个等待窗口。 - if error.kind() == std::io::ErrorKind::PermissionDenied { - return ProjectWriteLockOpenFailure::Permission; - } - } - } - ProjectWriteLockOpenFailure::Other -} - -/// 一次取锁失败的完整形状。 -/// -/// 等待层需要它做两件事:按分类决定是否重试,以及在预算耗尽后用**当时的**目标状态做终态 -/// 投影。把"这一次失败"整份传下去,调用方就不必回头解析错误文案。 -#[derive(Debug)] -pub(crate) enum ProjectWriteLockFailure { - /// 目标被占用、删除挂起或正处于删除拆链窗口:允许进入有界等待。 - /// 保留 `create_new` 的原始错误,等待层才能在预算耗尽后做终态投影。 - Retryable { - path: PathBuf, - source: std::io::Error, - }, - /// 已经定稿、不可重试的失败文案:权限拒绝、其它 I/O 错误、前置校验失败。 - Terminal(String), -} - -/// Windows 上 `ACCESS_DENIED(5)` 既可能是删除拆链窗口,也可能是真实权限拒绝, -/// 错误码本身不可区分;只有这一类的可重试失败才可能在等待之后被改判。 -fn project_write_lock_permission_is_ambiguous(source: &std::io::Error) -> bool { - source.kind() == std::io::ErrorKind::PermissionDenied -} - -impl ProjectWriteLockFailure { - /// 是否允许进入有界等待。判据是失败分类,不是错误文案。 - pub(crate) fn is_retryable(&self) -> bool { - matches!(self, Self::Retryable { .. }) - } - - /// 零等待入口的文案。可重试的失败保持争用前缀逐字不变:`provider_recovery.rs`、 - /// `planning_session_v2.rs`、`direct_runtime.rs` 和前端 `App.tsx` 都按这个前缀把错误 - /// 当成可等待的瞬时状态,改前缀等于顺手改掉它们的重试语义。 - pub(crate) fn message(&self) -> String { - match self { - Self::Terminal(message) => message.clone(), - Self::Retryable { path, source } => project_write_lock_contention_error( - path, - ProjectWriteLockSnapshot::read(path).as_ref(), - project_write_lock_permission_is_ambiguous(source) && !path.exists(), - ), - } - } - - /// 等待预算耗尽后的终态投影:`(日志分类, 给用户的文案)`。 - /// - /// 删除拆链窗口是微秒级:真的等过预算(`waited`)仍在失败、且目标此刻仍然不存在, - /// 说明这不是瞬时争用而是权限 / ACL 拒绝,此时才改判。单次试探(`max_attempts == 1`) - /// 没有等待证据,保持争用语义,不做终态改判。 - pub(crate) fn exhausted_projection(&self, waited: bool) -> (&'static str, String) { - let Self::Retryable { path, source } = self else { - return ("terminal", self.message()); - }; - if waited && project_write_lock_permission_is_ambiguous(source) && !path.exists() { - return ( - "permission_denied", - project_write_lock_permission_error(path, source), - ); - } - ("contention", self.message()) - } -} - -/// 争用错误必须带上持锁方身份。锁文件处于 delete-pending 或尚未写完时读不到身份, -/// 也必须显式表达成"不可读",不能默认成"没有持锁方"。 -/// -/// `permission_ambiguous` 为真表示目标此刻不存在、而错误码是 Windows 上无法与权限拒绝 -/// 区分的 `ACCESS_DENIED(5)`:零等待入口没有等待窗口可以证伪,文案必须把两种处置都说 -/// 出来,而不是替调用方猜一个。 -fn project_write_lock_contention_error( - path: &Path, - snapshot: Option<&ProjectWriteLockSnapshot>, - permission_ambiguous: bool, -) -> String { - match snapshot { - Some(snapshot) => format!( - "{PROJECT_WRITE_LOCK_CONTENTION_PREFIX}{}(持锁方 {})", - path.display(), - snapshot.describe_holder() - ), - None if permission_ambiguous => format!( - "{PROJECT_WRITE_LOCK_CONTENTION_PREFIX}{}(持锁方身份不可读:锁文件此刻不存在,可能是删除挂起、删除拆链窗口或权限 / ACL 拒绝)", - path.display() - ), - 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(), - } -} - -/// **重试性只由错误码决定,不由一次 metadata 观察决定。** 这条用例把平台作为参数, -/// 因此 CI 的 Linux runner 也会执行 Windows 分支:删除拆链窗口里 `create_new` 报 -/// `ACCESS_DENIED(5)` 而目标已经不可见,用 `exists()` 判"要不要等"会把这批瞬时失败判死。 -#[test] -fn project_write_lock_retryability_comes_from_the_error_code_not_a_metadata_probe() { - for error in [ - std::io::Error::from_raw_os_error(5), - std::io::Error::from_raw_os_error(32), - std::io::Error::from_raw_os_error(33), - std::io::Error::from(std::io::ErrorKind::PermissionDenied), - std::io::Error::from(std::io::ErrorKind::AlreadyExists), - ] { - assert_eq!( - project_write_lock_open_failure_for(ProjectWriteLockPlatform::Windows, &error), - ProjectWriteLockOpenFailure::Retryable, - "Windows 上 {error:?} 必须进入有界等待" - ); - } - assert_eq!( - project_write_lock_open_failure_for( - ProjectWriteLockPlatform::Unix, - &std::io::Error::from(std::io::ErrorKind::AlreadyExists) - ), - ProjectWriteLockOpenFailure::Retryable, - "Unix 上目标存在必须是争用" - ); - // Unix 没有删除挂起,EACCES 就是权限拒绝,可以立刻判定,不必白等一个等待窗口。 - assert_eq!( - project_write_lock_open_failure_for( - ProjectWriteLockPlatform::Unix, - &std::io::Error::from(std::io::ErrorKind::PermissionDenied) - ), - ProjectWriteLockOpenFailure::Permission - ); - for platform in [ - ProjectWriteLockPlatform::Windows, - ProjectWriteLockPlatform::Unix, - ] { - assert_eq!( - project_write_lock_open_failure_for( - platform, - &std::io::Error::from(std::io::ErrorKind::NotFound) - ), - ProjectWriteLockOpenFailure::Other, - "其它 I/O 错误必须原样上报,不进入等待" - ); - } -} - -/// 终态改判的三个条件必须同时成立:真的等过预算、目标此刻仍不存在、错误码是 Windows 上 -/// 不可区分的 `ACCESS_DENIED(5)`。缺任何一个都保持争用语义(前缀逐字不变)。 -#[test] -fn project_write_lock_exhausted_projection_needs_a_waited_budget_and_a_missing_target() { - let temporary = tempfile::tempdir().expect("create projection root"); - let path = temporary.path().join("project.lock"); - let ambiguous = ProjectWriteLockFailure::Retryable { - path: path.clone(), - source: std::io::Error::from_raw_os_error(5), - }; - assert!( - ambiguous.is_retryable(), - "ACCESS_DENIED 必须允许进入有界等待" - ); - - // 目标不存在 + 真的等过预算:改判权限拒绝,文案不得再含争用前缀。 - let (projection, message) = ambiguous.exhausted_projection(true); - assert_eq!(projection, "permission_denied"); - assert!( - !message.starts_with(PROJECT_WRITE_LOCK_CONTENTION_PREFIX), - "预算耗尽且目标缺失时不得再投影成争用:{message}" - ); - - // 同一形状的单次试探没有等待证据:保持争用语义,前缀逐字不变。 - let (projection, message) = ambiguous.exhausted_projection(false); - assert_eq!(projection, "contention"); - assert!( - message.starts_with(PROJECT_WRITE_LOCK_CONTENTION_PREFIX), - "{message}" - ); - - // 目标此刻存在(真实争用或带句柄的删除挂起):永远按争用上报。 - fs::write(&path, b"{}").expect("write a visible lock fixture"); - let (projection, message) = ambiguous.exhausted_projection(true); - assert_eq!(projection, "contention"); - assert!( - message.starts_with(PROJECT_WRITE_LOCK_CONTENTION_PREFIX), - "{message}" - ); - fs::remove_file(&path).expect("remove lock fixture"); - - // 32 / 33 只可能在目标被占用时出现,不会因为目标缺失被改判成权限拒绝。 - let occupied = ProjectWriteLockFailure::Retryable { - path: path.clone(), - source: std::io::Error::from_raw_os_error(32), - }; - assert_eq!(occupied.exhausted_projection(true).0, "contention"); - - // 明确判定的权限拒绝(Unix EACCES)不携带争用前缀,调用方不会当成瞬时状态。 - let denied = ProjectWriteLockFailure::Terminal(project_write_lock_permission_error( - &path, - &std::io::Error::from(std::io::ErrorKind::PermissionDenied), - )); - assert!(!denied.is_retryable()); - assert!( - !denied - .message() - .starts_with(PROJECT_WRITE_LOCK_CONTENTION_PREFIX), - "{}", - denied.message() - ); -} - -#[cfg(all(test, windows))] -#[test] -fn project_write_lock_hardens_space_containing_path_in_process() { - let parent = tempfile::tempdir().expect("create spaced lock parent"); - let root = parent - .path() - .join("Genarrative GameAgent") - .join("gameagent-space"); - fs::create_dir_all(&root).expect("create spaced project root"); - let lock = acquire_project_write_lock(&root, "planning.v2.approval") - .expect("acquire project lock under a space-containing path"); - let lock_path = root.join(".agent").join("project.lock"); - assert!(lock_path.is_file(), "project lock must exist while held"); - crate::secure_windows_game_creator_path_for_current_user(&lock_path, false, false) - .expect("new project lock must already satisfy the private DACL contract"); - drop(lock); - assert!( - !lock_path.exists(), - "project lock must be removed when the guard is dropped" - ); -} - -fn resolve_project_write_lock_path(root: &Path) -> Result { - let normalized = normalize_relative_path(PROJECT_WRITE_LOCK_PATH)?; - let (parent_relative, file_name) = normalized - .rsplit_once('/') - .ok_or_else(|| "项目写锁路径必须包含安全父目录".to_string())?; - let parent = resolve_local_project_path(root, parent_relative)?; - // create_new is the authority for the final lock component. On Windows a - // delete-pending lock can make a metadata preflight fail with ACCESS_DENIED - // before the existing bounded contention wait has a chance to run. - Ok(parent.join(file_name)) -} - -pub(crate) fn acquire_project_write_lock( - root: &Path, - command_id: &str, -) -> Result { - acquire_project_write_lock_failure(root, command_id).map_err(|failure| failure.message()) -} - -/// 与 `acquire_project_write_lock` 同一实现,但把失败分类交给调用方。 -/// -/// 有界等待必须按失败类型决定是否重试:用错误文案前缀做控制流时,改一次文案就等于改一次 -/// 重试语义。前置校验失败没有可重试语义,统一作为终态文案上报。 -pub(crate) fn acquire_project_write_lock_failure( - root: &Path, - command_id: &str, -) -> Result { - validate_project_root(root).map_err(ProjectWriteLockFailure::Terminal)?; - let mut path = - resolve_project_write_lock_path(root).map_err(ProjectWriteLockFailure::Terminal)?; - if let Some(parent) = path.parent() { - ensure_game_creator_private_directory_tree(parent, "项目锁目录") - .map_err(ProjectWriteLockFailure::Terminal)?; - prepare_game_creator_private_path_for_read(parent, true, "项目锁目录") - .map_err(ProjectWriteLockFailure::Terminal)?; - } - // Re-check the parent after creation so skipping metadata only for the final - // create_new target cannot weaken the normal ancestor link/reparse checks. - path = resolve_project_write_lock_path(root).map_err(ProjectWriteLockFailure::Terminal)?; - let payload = serde_json::json!({ - "commandId": command_id, - "pid": std::process::id(), - // 进程启动身份:崩溃残留锁要靠它区分“PID 被复用”和“持有者仍然活着”。 - "processStartedAt": project_write_lock_process_start_time_seconds(u64::from( - std::process::id() - )), - "createdAt": unix_timestamp(), - "nonce": PROJECT_WRITE_LOCK_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed), - }); - let content = serde_json::to_string_pretty(&payload) - .map_err(|error| format!("生成项目写锁失败:{error}")) - .map_err(ProjectWriteLockFailure::Terminal)?; - let mut retried_after_reclaim = false; - loop { - let mut options = fs::OpenOptions::new(); - options.create_new(true).write(true); - #[cfg(windows)] - { - use std::os::windows::fs::OpenOptionsExt; - options.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); - } - match options.open(&path) { - Ok(mut file) => { - if let Err(error) = file.write_all(content.as_bytes()) { - drop(file); - let _ = fs::remove_file(&path); - return Err(ProjectWriteLockFailure::Terminal(format!( - "写入项目写锁失败:{}: {error}", - path.display() - ))); - } - if let Err(error) = file.sync_all() { - drop(file); - let _ = fs::remove_file(&path); - return Err(ProjectWriteLockFailure::Terminal(format!( - "落盘项目写锁失败:{}: {error}", - path.display() - ))); - } - drop(file); - if let Err(error) = harden_new_game_creator_private_path(&path, false, "项目写锁") - { - let _ = fs::remove_file(&path); - return Err(ProjectWriteLockFailure::Terminal(error)); - } - let actual = match fs::read_to_string(&path) { - Ok(actual) => actual, - Err(error) => { - let _ = fs::remove_file(&path); - return Err(ProjectWriteLockFailure::Terminal(format!( - "读取项目写锁失败:{}: {error}", - path.display() - ))); - } - }; - if actual != content { - let _ = fs::remove_file(&path); - return Err(ProjectWriteLockFailure::Terminal(format!( - "项目写锁内容校验失败:{}", - path.display() - ))); - } - return Ok(ProjectWriteLock { - path, - content: content.clone(), - bypassed_same_process: false, - }); - } - Err(error) => { - // 是否可重试只看错误码(平台判据见 `project_write_lock_open_failure_for`): - // 拿 `path.exists()` 当场判死会在删除拆链窗口里把瞬时争用变成永久失败。 - let failure = - project_write_lock_open_failure_for(PROJECT_WRITE_LOCK_PLATFORM, &error); - if failure == ProjectWriteLockOpenFailure::Permission { - // 只有 Unix 的 EACCES 能在这里被明确判定(Windows 的 ACCESS_DENIED - // 归可重试,终态由等待方在预算耗尽后投影)。权限拒绝不会重试,所以在 - // 这里记录:它必须能在 App 日志里和"别人正在写"区分开。 - app_log!( - "project.write_lock.permission_denied commandId={command_id} path={} osError={:?}", - path.display(), - error.raw_os_error() - ); - return Err(ProjectWriteLockFailure::Terminal( - project_write_lock_permission_error(&path, &error), - )); - } - if failure == ProjectWriteLockOpenFailure::Other { - return Err(ProjectWriteLockFailure::Terminal(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) - .map_err(ProjectWriteLockFailure::Terminal)? - { - app_log!( - "project.write_lock.reclaim_stale commandId={command_id} path={} holder={}", - path.display(), - snapshot.describe_holder() - ); - retried_after_reclaim = true; - continue; - } - } - } - if crate::agent::autonomous_game_build_root_run_active_at(root) - && project_write_lock_is_owned_by_current_process(&path) - { - // The autonomous game-build lane intentionally permits - // parallel specialist actions. If the durable lock belongs - // to this very process, contention is an in-process overlap, - // not another application editing the project. Return an - // advisory guard and leave the real lock untouched. - return Ok(ProjectWriteLock { - path, - content: String::new(), - bypassed_same_process: true, - }); - } - // 争用不在零等待入口里记日志:有界等待会把这个函数调用上千次, - // 每次记一行会淹掉日志。等待方在预算耗尽时记一条带等待时长的记录。 - return Err(ProjectWriteLockFailure::Retryable { - path: path.clone(), - source: error, - }); - } - } - } -} - pub(crate) fn list_local_project_files_at( root: &Path, ) -> Result { @@ -1426,7 +571,9 @@ pub(crate) fn validate_project_root(root: &Path) -> Result<(), String> { Ok(()) } -fn windows_metadata_is_reparse_point(metadata: &fs::Metadata) -> bool { +/// 路径是否是指向别处的 reparse point(符号链接、junction 等)。项目锁文件与项目文件 +/// 遍历都要靠它拒绝“名字在项目里、内容在项目外”的对象,因此对 `write_lock` 可见。 +pub(crate) fn windows_metadata_is_reparse_point(metadata: &fs::Metadata) -> bool { #[cfg(windows)] { use std::os::windows::fs::MetadataExt; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs b/apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs new file mode 100644 index 000000000..bb4f9d323 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs @@ -0,0 +1,856 @@ +use super::*; + +#[cfg(windows)] +pub(crate) const PROJECT_FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + +static PROJECT_WRITE_LOCK_NONCE: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(1); +const PROJECT_WRITE_LOCK_STALE_AFTER_SECONDS: u64 = 600; +/// 崩溃可能停在 `create_new` 成功、payload 落盘之前,此时锁文件没有任何持有者 +/// 信息。写入方正常情况下在毫秒级完成落盘,所以只需要很短的宽限期就能确认它 +/// 已经放弃,而不是让项目在整整 10 分钟里都不可写。 +const PROJECT_WRITE_LOCK_UNWRITTEN_GRACE_SECONDS: u64 = 30; +/// 进程启动时间与锁 `createdAt` 之间的允许偏差(秒),用来抵消时间戳精度差异。 +const PROJECT_WRITE_LOCK_PID_REUSE_TOLERANCE_SECONDS: u64 = 5; +const PROJECT_WRITE_LOCK_MAX_BYTES: u64 = 4 * 1024; + +#[derive(Debug)] +pub(crate) struct ProjectWriteLock { + path: PathBuf, + content: String, + /// In the free-form autonomous lane a single Runtime process may have + /// several specialist actions in flight at once. A file lock is still + /// useful across processes, but making same-process contenders fail turns + /// ordinary parallel work into a dead run (and can deadlock nested tool + /// calls). Such a contender receives an in-process/advisory guard instead + /// of deleting the real holder's lock on drop. + bypassed_same_process: bool, +} + +impl ProjectWriteLock { + pub(crate) fn guards_project_root(&self, root: &Path) -> Result { + let expected_path = resolve_local_project_path(root, PROJECT_WRITE_LOCK_PATH)?; + if self.bypassed_same_process { + // The relaxed guard deliberately has no ownership of the durable + // `.agent/project.lock` file. It still binds the observation to + // the validated project root so callers cannot use a guard from a + // different project. + return Ok(self.path == expected_path); + } + Ok(self.path == expected_path + && fs::read_to_string(&self.path).is_ok_and(|content| content == self.content)) + } +} + +impl Drop for ProjectWriteLock { + fn drop(&mut self) { + if self.bypassed_same_process { + return; + } + if fs::read_to_string(&self.path).is_ok_and(|content| content == self.content) { + let _ = fs::remove_file(&self.path); + } + } +} + +#[cfg(unix)] +fn project_write_lock_process_is_alive(process_id: u64) -> Option { + // Unix 的 pid_t 是有符号 32 位且恒大于 0,超出该范围的取值不可能是本机 + // 任何进程,说明锁文件里的 PID 已经损坏,可以直接判定持有者不存在。 + let Some(process_id) = i32::try_from(process_id).ok().filter(|value| *value > 0) else { + return Some(false); + }; + let result = unsafe { libc::kill(process_id, 0) }; + if result == 0 { + return Some(true); + } + match std::io::Error::last_os_error().raw_os_error() { + Some(libc::ESRCH) => Some(false), + Some(libc::EPERM) => Some(true), + _ => None, + } +} + +#[cfg(windows)] +fn project_write_lock_process_is_alive(process_id: u64) -> Option { + use std::ffi::c_void; + + #[link(name = "kernel32")] + unsafe extern "system" { + fn OpenProcess(access: u32, inherit_handle: i32, process_id: u32) -> *mut c_void; + fn GetExitCodeProcess(process: *mut c_void, exit_code: *mut u32) -> i32; + fn CloseHandle(handle: *mut c_void) -> i32; + } + + // Windows 进程号是 32 位且恒大于 0,超出该范围的取值不可能是本机任何 + // 进程,说明锁文件里的 PID 已经损坏,可以直接判定持有者不存在。 + let Some(process_id) = u32::try_from(process_id).ok().filter(|value| *value > 0) else { + return Some(false); + }; + const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000; + const STILL_ACTIVE: u32 = 259; + // SAFETY: OpenProcess returns an owned kernel handle or null; it is + // closed below. We only request the query permission needed here. + let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, process_id) }; + if process.is_null() { + // ERROR_INVALID_PARAMETER means the process no longer exists. For + // access-denied/other failures we cannot prove liveness, so keep the + // conservative unknown result and let the normal bounded wait decide. + return match std::io::Error::last_os_error().raw_os_error() { + Some(87) => Some(false), + _ => None, + }; + } + let mut exit_code = 0_u32; + // SAFETY: `exit_code` is a writable scalar and `process` is a live handle. + let result = unsafe { GetExitCodeProcess(process, &mut exit_code) }; + // SAFETY: `process` is an owned handle returned by OpenProcess. + unsafe { CloseHandle(process) }; + if result == 0 { + return None; + } + Some(exit_code == STILL_ACTIVE) +} + +#[cfg(not(any(unix, windows)))] +fn project_write_lock_process_is_alive(_process_id: u64) -> Option { + None +} + +/// 读取进程的启动时间(Unix 秒)。用来区分“锁记录里的 PID 仍然属于原来的持有 +/// 者”和“PID 已经被系统复用给另一个进程”。无法判定的平台返回 `None`,此时 +/// 保持原有的保守回收策略。 +#[cfg(windows)] +pub(crate) fn project_write_lock_process_start_time_seconds(process_id: u64) -> Option { + use std::ffi::c_void; + + #[repr(C)] + struct FileTime { + low_date_time: u32, + high_date_time: u32, + } + + #[link(name = "kernel32")] + unsafe extern "system" { + fn OpenProcess(access: u32, inherit_handle: i32, process_id: u32) -> *mut c_void; + fn GetProcessTimes( + process: *mut c_void, + creation_time: *mut FileTime, + exit_time: *mut FileTime, + kernel_time: *mut FileTime, + user_time: *mut FileTime, + ) -> i32; + fn CloseHandle(handle: *mut c_void) -> i32; + } + + const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000; + /// Windows FILETIME 起点(1601-01-01)到 Unix 纪元之间的 100 纳秒数。 + const FILETIME_UNIX_EPOCH_OFFSET: u64 = 116_444_736_000_000_000; + let process_id = u32::try_from(process_id).ok().filter(|value| *value > 0)?; + // SAFETY: OpenProcess returns an owned kernel handle or null; it is closed below. + let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, process_id) }; + if process.is_null() { + return None; + } + // SAFETY: every FileTime is plain data filled by GetProcessTimes. + let mut creation = unsafe { std::mem::zeroed::() }; + let mut exit = unsafe { std::mem::zeroed::() }; + let mut kernel = unsafe { std::mem::zeroed::() }; + let mut user = unsafe { std::mem::zeroed::() }; + // SAFETY: `process` is a live handle and all four pointers are writable scalars. + let result = + unsafe { GetProcessTimes(process, &mut creation, &mut exit, &mut kernel, &mut user) }; + // SAFETY: `process` is an owned handle returned by OpenProcess. + unsafe { CloseHandle(process) }; + if result == 0 { + return None; + } + let file_time = (u64::from(creation.high_date_time) << 32) | u64::from(creation.low_date_time); + file_time + .checked_sub(FILETIME_UNIX_EPOCH_OFFSET) + .map(|unix_100ns| unix_100ns / 10_000_000) +} + +#[cfg(target_os = "linux")] +pub(crate) fn project_write_lock_process_start_time_seconds(process_id: u64) -> Option { + let process_id = u32::try_from(process_id).ok().filter(|value| *value > 0)?; + // SAFETY: sysconf has no memory safety preconditions and returns -1 on failure. + let clock_ticks = unsafe { libc::sysconf(libc::_SC_CLK_TCK) }; + if clock_ticks <= 0 { + return None; + } + let stat = fs::read_to_string(format!("/proc/{process_id}/stat")).ok()?; + let start_ticks = stat + .rsplit_once(") ")? + .1 + .split_whitespace() + .nth(19)? + .parse::() + .ok()?; + let boot_time = fs::read_to_string("/proc/stat") + .ok()? + .lines() + .find_map(|line| line.strip_prefix("btime "))? + .trim() + .parse::() + .ok()?; + Some(boot_time + start_ticks / clock_ticks as u64) +} + +#[cfg(not(any(windows, target_os = "linux")))] +pub(crate) fn project_write_lock_process_start_time_seconds(_process_id: u64) -> Option { + None +} + +/// 一次读到的锁文件字节与解析结果。回收判据和随后的删除必须基于同一份快照: +/// 分别重读 `pid` / `createdAt` / `processStartedAt` 会把旧 inode 的持有者信息 +/// 和新 inode 的启动身份拼在一起,也会让判定与删除命中不同的文件。 +#[derive(Debug, Clone)] +pub(crate) struct ProjectWriteLockSnapshot { + content: Vec, + command_id: Option, + pid: Option, + created_at: Option, + process_started_at: Option, +} + +impl ProjectWriteLockSnapshot { + pub(crate) fn read(path: &Path) -> Option { + let content = fs::read(path).ok()?; + let payload = serde_json::from_slice::(&content).ok(); + let number = |key: &str| { + payload + .as_ref() + .and_then(|payload| payload.get(key)) + .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 { + ProjectWriteLockSnapshot::read(path).and_then(|snapshot| snapshot.pid) + == Some(u64::from(std::process::id())) +} + +/// 读取锁文件 mtime 的 Unix 秒数;读不到时返回 `None`。调用方必须把“mtime 未知” +/// 和“mtime 等于纪元 0”区分开:后者会被算成极大的年龄,反而把保守判定反转成 +/// “立刻回收”,甚至把活持有者的锁当成 PID 复用抢走。 +fn project_write_lock_file_modified_seconds(metadata: &fs::Metadata) -> Option { + metadata + .modified() + .ok() + .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_secs()) +} + +/// 锁文件年龄(秒)。`createdAt` 与 mtime 都无法确定时返回 `None`:未知年龄只能 +/// 按“不回收”处理,不能退化成 0 或极大值。 +fn project_write_lock_age_seconds( + snapshot: &ProjectWriteLockSnapshot, + modified_at: Option, + now: u64, +) -> Option { + if let Some(created_at) = snapshot.created_at { + return Some(now.saturating_sub(created_at)); + } + modified_at.map(|modified_at| now.saturating_sub(modified_at)) +} + +/// 回收判据。进程存活与启动时间查询作为参数传入,便于用确定性用例覆盖真实进程 +/// 难以构造的分支(存活状态无法判定、mtime 不可读)。 +pub(crate) fn project_write_lock_reclaim_decision( + snapshot: &ProjectWriteLockSnapshot, + modified_at: Option, + now: u64, + process_is_alive: impl Fn(u64) -> Option, + process_started_at: impl Fn(u64) -> Option, +) -> bool { + let Some(owner_pid) = snapshot.pid else { + // 没有可用的持有者信息(空锁、坏锁、无数字 pid 的锁):只按短宽限期回收。 + return project_write_lock_age_seconds(snapshot, modified_at, now) + .is_some_and(|age| age > PROJECT_WRITE_LOCK_UNWRITTEN_GRACE_SECONDS); + }; + match process_is_alive(owner_pid) { + Some(false) => true, + Some(true) => { + // PID 会被系统复用,必须确认当前同名进程就是当时的持有者。 + match (snapshot.process_started_at, process_started_at(owner_pid)) { + // 新锁自带启动身份:同一进程的身份恒定,不一致即为 PID 复用。 + (Some(stored), Some(actual)) => stored != actual, + // 旧锁没有启动身份,只能用“启动时间晚于锁创建时间”推断 PID 复用; + // 锁创建时间未知时不做推断,避免把“未知”当成“复用”抢走活持有者。 + (None, Some(actual)) => { + let Some(lock_created_at) = snapshot.created_at.or(modified_at) else { + return false; + }; + actual + > lock_created_at + .saturating_add(PROJECT_WRITE_LOCK_PID_REUSE_TOLERANCE_SECONDS) + } + _ => false, + } + } + // 无法判定持有者是否存活时保持保守策略:只有明显过期才回收。 + None => project_write_lock_age_seconds(snapshot, modified_at, now) + .is_some_and(|age| age > PROJECT_WRITE_LOCK_STALE_AFTER_SECONDS), + } +} + +/// 判定残留锁可回收时返回判定所依据的快照,否则返回 `None`。 +fn project_write_lock_reclaimable_snapshot(path: &Path) -> Option { + let metadata = fs::symlink_metadata(path).ok()?; + if metadata.file_type().is_symlink() + || windows_metadata_is_reparse_point(&metadata) + || !metadata.is_file() + || metadata.len() > PROJECT_WRITE_LOCK_MAX_BYTES + { + return None; + } + let snapshot = ProjectWriteLockSnapshot::read(path)?; + project_write_lock_reclaim_decision( + &snapshot, + project_write_lock_file_modified_seconds(&metadata), + unix_timestamp(), + project_write_lock_process_is_alive, + project_write_lock_process_start_time_seconds, + ) + .then_some(snapshot) +} + +/// 删除判定为残留的锁文件。判定只是快照观察,删除前必须重新核对字节,确认删掉的 +/// 仍是判定时的那个文件:并发方可能已经回收并装上了自己的活锁。文件已经消失或 +/// 已被替换时返回 `false`,让调用方重试 `create_new` 重新竞争,而不是报错。 +pub(crate) fn project_write_lock_reclaim( + path: &Path, + snapshot: &ProjectWriteLockSnapshot, +) -> Result { + match fs::read(path) { + Ok(content) if content == snapshot.content => {} + Ok(_) => return Ok(false), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => { + return Err(format!("读取失效项目写锁失败:{}: {error}", path.display())); + } + } + match fs::remove_file(path) { + Ok(()) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(format!("清理失效项目写锁失败:{}: {error}", path.display())), + } +} + +/// `.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 = "项目正在被其他写操作占用:"; + +/// 锁分类判据必须能被两个平台覆盖,所以平台由参数传入而不是藏在 `#[cfg]` 后面: +/// CI 只有 Linux runner,`#[cfg(windows)]` 的用例在 CI 里一次都不会跑,而 Windows 特有的 +/// `ACCESS_DENIED(5)` / sharing violation(32) / lock violation(33) 分支恰恰是最危险的一段。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ProjectWriteLockPlatform { + Windows, + Unix, +} + +pub(crate) const PROJECT_WRITE_LOCK_PLATFORM: ProjectWriteLockPlatform = if cfg!(windows) { + ProjectWriteLockPlatform::Windows +} else { + ProjectWriteLockPlatform::Unix +}; + +/// 一次 `create_new` 失败在**等待契约**上的归类。三类的处置完全不同:可重试、权限拒绝 +/// 必须失败关闭、其它 I/O 错误原样上报。混成一句「项目正在被其他写操作占用」会把 ACL +/// 问题、删除挂起和真实跨进程争用一起藏起来(Issue #318 第 3 条)。 +/// +/// 判据只能是错误码本身。**不要用 `path.exists()` 这种一次 metadata 观察决定"要不要重试"**: +/// 目标被删除时目录项先消失、删除挂起随后才结束,`create_new` 会在这个拆链窗口里返回 +/// `ACCESS_DENIED(5)`,而 `exists()` 往往已经报 false。本机实测 60000 次建锁 / 删锁竞争里 +/// 有 396-538 例命中"5 + 目标不可见";按"目标不存在"判成权限拒绝,等待层就会立刻失败关闭 +/// ——正是 Issue #318 要消灭的"毫秒级直接失败",只是换成了更误导的 ACL 文案。 +/// 终态投影放在等待预算耗尽之后做,见 `ProjectWriteLockFailure::exhausted_projection`。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProjectWriteLockOpenFailure { + /// 目标被占用、删除挂起或正处于删除拆链窗口:短暂重叠,可重试。 + Retryable, + /// 明确不是争用:权限 / ACL 拒绝,失败关闭。 + Permission, + /// 其它 I/O 错误,原样上报。 + Other, +} + +fn project_write_lock_open_failure_for( + platform: ProjectWriteLockPlatform, + error: &std::io::Error, +) -> ProjectWriteLockOpenFailure { + if error.kind() == std::io::ErrorKind::AlreadyExists { + return ProjectWriteLockOpenFailure::Retryable; + } + match platform { + ProjectWriteLockPlatform::Windows => { + // 32 / 33 是 sharing / lock violation,只可能在目标被占用时出现,恒定可重试。 + // ACCESS_DENIED(5) 既可能是 delete-pending / 删除拆链窗口,也可能是真实 ACL + // 拒绝,错误码上不可区分,因此同样先按可重试处理,由等待方在预算耗尽后再判定终态。 + if matches!(error.raw_os_error(), Some(5 | 32 | 33)) + || error.kind() == std::io::ErrorKind::PermissionDenied + { + return ProjectWriteLockOpenFailure::Retryable; + } + } + ProjectWriteLockPlatform::Unix => { + // Unix 没有删除挂起:目标存在必然先命中 AlreadyExists,EACCES 就是权限拒绝, + // 可以立刻判定,不必让调用方白等一个等待窗口。 + if error.kind() == std::io::ErrorKind::PermissionDenied { + return ProjectWriteLockOpenFailure::Permission; + } + } + } + ProjectWriteLockOpenFailure::Other +} + +/// 一次取锁失败的完整形状。 +/// +/// 等待层需要它做两件事:按分类决定是否重试,以及在预算耗尽后用**当时的**目标状态做终态 +/// 投影。把"这一次失败"整份传下去,调用方就不必回头解析错误文案。 +#[derive(Debug)] +pub(crate) enum ProjectWriteLockFailure { + /// 目标被占用、删除挂起或正处于删除拆链窗口:允许进入有界等待。 + /// 保留 `create_new` 的原始错误,等待层才能在预算耗尽后做终态投影。 + Retryable { + path: PathBuf, + source: std::io::Error, + }, + /// 已经定稿、不可重试的失败文案:权限拒绝、其它 I/O 错误、前置校验失败。 + Terminal(String), +} + +/// Windows 上 `ACCESS_DENIED(5)` 既可能是删除拆链窗口,也可能是真实权限拒绝, +/// 错误码本身不可区分;只有这一类的可重试失败才可能在等待之后被改判。 +fn project_write_lock_permission_is_ambiguous(source: &std::io::Error) -> bool { + source.kind() == std::io::ErrorKind::PermissionDenied +} + +impl ProjectWriteLockFailure { + /// 是否允许进入有界等待。判据是失败分类,不是错误文案。 + pub(crate) fn is_retryable(&self) -> bool { + matches!(self, Self::Retryable { .. }) + } + + /// 零等待入口的文案。可重试的失败保持争用前缀逐字不变:`provider_recovery.rs`、 + /// `planning_session_v2.rs`、`direct_runtime.rs` 和前端 `App.tsx` 都按这个前缀把错误 + /// 当成可等待的瞬时状态,改前缀等于顺手改掉它们的重试语义。 + pub(crate) fn message(&self) -> String { + match self { + Self::Terminal(message) => message.clone(), + Self::Retryable { path, source } => project_write_lock_contention_error( + path, + ProjectWriteLockSnapshot::read(path).as_ref(), + project_write_lock_permission_is_ambiguous(source) && !path.exists(), + ), + } + } + + /// 等待预算耗尽后的终态投影:`(日志分类, 给用户的文案)`。 + /// + /// 删除拆链窗口是微秒级:真的等过预算(`waited`)仍在失败、且目标此刻仍然不存在, + /// 说明这不是瞬时争用而是权限 / ACL 拒绝,此时才改判。单次试探(`max_attempts == 1`) + /// 没有等待证据,保持争用语义,不做终态改判。 + pub(crate) fn exhausted_projection(&self, waited: bool) -> (&'static str, String) { + let Self::Retryable { path, source } = self else { + return ("terminal", self.message()); + }; + if waited && project_write_lock_permission_is_ambiguous(source) && !path.exists() { + return ( + "permission_denied", + project_write_lock_permission_error(path, source), + ); + } + ("contention", self.message()) + } +} + +/// 争用错误必须带上持锁方身份。锁文件处于 delete-pending 或尚未写完时读不到身份, +/// 也必须显式表达成"不可读",不能默认成"没有持锁方"。 +/// +/// `permission_ambiguous` 为真表示目标此刻不存在、而错误码是 Windows 上无法与权限拒绝 +/// 区分的 `ACCESS_DENIED(5)`:零等待入口没有等待窗口可以证伪,文案必须把两种处置都说 +/// 出来,而不是替调用方猜一个。 +fn project_write_lock_contention_error( + path: &Path, + snapshot: Option<&ProjectWriteLockSnapshot>, + permission_ambiguous: bool, +) -> String { + match snapshot { + Some(snapshot) => format!( + "{PROJECT_WRITE_LOCK_CONTENTION_PREFIX}{}(持锁方 {})", + path.display(), + snapshot.describe_holder() + ), + None if permission_ambiguous => format!( + "{PROJECT_WRITE_LOCK_CONTENTION_PREFIX}{}(持锁方身份不可读:锁文件此刻不存在,可能是删除挂起、删除拆链窗口或权限 / ACL 拒绝)", + path.display() + ), + 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(), + } +} + +/// **重试性只由错误码决定,不由一次 metadata 观察决定。** 这条用例把平台作为参数, +/// 因此 CI 的 Linux runner 也会执行 Windows 分支:删除拆链窗口里 `create_new` 报 +/// `ACCESS_DENIED(5)` 而目标已经不可见,用 `exists()` 判"要不要等"会把这批瞬时失败判死。 +#[test] +fn project_write_lock_retryability_comes_from_the_error_code_not_a_metadata_probe() { + for error in [ + std::io::Error::from_raw_os_error(5), + std::io::Error::from_raw_os_error(32), + std::io::Error::from_raw_os_error(33), + std::io::Error::from(std::io::ErrorKind::PermissionDenied), + std::io::Error::from(std::io::ErrorKind::AlreadyExists), + ] { + assert_eq!( + project_write_lock_open_failure_for(ProjectWriteLockPlatform::Windows, &error), + ProjectWriteLockOpenFailure::Retryable, + "Windows 上 {error:?} 必须进入有界等待" + ); + } + assert_eq!( + project_write_lock_open_failure_for( + ProjectWriteLockPlatform::Unix, + &std::io::Error::from(std::io::ErrorKind::AlreadyExists) + ), + ProjectWriteLockOpenFailure::Retryable, + "Unix 上目标存在必须是争用" + ); + // Unix 没有删除挂起,EACCES 就是权限拒绝,可以立刻判定,不必白等一个等待窗口。 + assert_eq!( + project_write_lock_open_failure_for( + ProjectWriteLockPlatform::Unix, + &std::io::Error::from(std::io::ErrorKind::PermissionDenied) + ), + ProjectWriteLockOpenFailure::Permission + ); + for platform in [ + ProjectWriteLockPlatform::Windows, + ProjectWriteLockPlatform::Unix, + ] { + assert_eq!( + project_write_lock_open_failure_for( + platform, + &std::io::Error::from(std::io::ErrorKind::NotFound) + ), + ProjectWriteLockOpenFailure::Other, + "其它 I/O 错误必须原样上报,不进入等待" + ); + } +} + +/// 终态改判的三个条件必须同时成立:真的等过预算、目标此刻仍不存在、错误码是 Windows 上 +/// 不可区分的 `ACCESS_DENIED(5)`。缺任何一个都保持争用语义(前缀逐字不变)。 +#[test] +fn project_write_lock_exhausted_projection_needs_a_waited_budget_and_a_missing_target() { + let temporary = tempfile::tempdir().expect("create projection root"); + let path = temporary.path().join("project.lock"); + let ambiguous = ProjectWriteLockFailure::Retryable { + path: path.clone(), + source: std::io::Error::from_raw_os_error(5), + }; + assert!( + ambiguous.is_retryable(), + "ACCESS_DENIED 必须允许进入有界等待" + ); + + // 目标不存在 + 真的等过预算:改判权限拒绝,文案不得再含争用前缀。 + let (projection, message) = ambiguous.exhausted_projection(true); + assert_eq!(projection, "permission_denied"); + assert!( + !message.starts_with(PROJECT_WRITE_LOCK_CONTENTION_PREFIX), + "预算耗尽且目标缺失时不得再投影成争用:{message}" + ); + + // 同一形状的单次试探没有等待证据:保持争用语义,前缀逐字不变。 + let (projection, message) = ambiguous.exhausted_projection(false); + assert_eq!(projection, "contention"); + assert!( + message.starts_with(PROJECT_WRITE_LOCK_CONTENTION_PREFIX), + "{message}" + ); + + // 目标此刻存在(真实争用或带句柄的删除挂起):永远按争用上报。 + fs::write(&path, b"{}").expect("write a visible lock fixture"); + let (projection, message) = ambiguous.exhausted_projection(true); + assert_eq!(projection, "contention"); + assert!( + message.starts_with(PROJECT_WRITE_LOCK_CONTENTION_PREFIX), + "{message}" + ); + fs::remove_file(&path).expect("remove lock fixture"); + + // 32 / 33 只可能在目标被占用时出现,不会因为目标缺失被改判成权限拒绝。 + let occupied = ProjectWriteLockFailure::Retryable { + path: path.clone(), + source: std::io::Error::from_raw_os_error(32), + }; + assert_eq!(occupied.exhausted_projection(true).0, "contention"); + + // 明确判定的权限拒绝(Unix EACCES)不携带争用前缀,调用方不会当成瞬时状态。 + let denied = ProjectWriteLockFailure::Terminal(project_write_lock_permission_error( + &path, + &std::io::Error::from(std::io::ErrorKind::PermissionDenied), + )); + assert!(!denied.is_retryable()); + assert!( + !denied + .message() + .starts_with(PROJECT_WRITE_LOCK_CONTENTION_PREFIX), + "{}", + denied.message() + ); +} + +#[cfg(all(test, windows))] +#[test] +fn project_write_lock_hardens_space_containing_path_in_process() { + let parent = tempfile::tempdir().expect("create spaced lock parent"); + let root = parent + .path() + .join("Genarrative GameAgent") + .join("gameagent-space"); + fs::create_dir_all(&root).expect("create spaced project root"); + let lock = acquire_project_write_lock(&root, "planning.v2.approval") + .expect("acquire project lock under a space-containing path"); + let lock_path = root.join(".agent").join("project.lock"); + assert!(lock_path.is_file(), "project lock must exist while held"); + crate::secure_windows_game_creator_path_for_current_user(&lock_path, false, false) + .expect("new project lock must already satisfy the private DACL contract"); + drop(lock); + assert!( + !lock_path.exists(), + "project lock must be removed when the guard is dropped" + ); +} + +fn resolve_project_write_lock_path(root: &Path) -> Result { + let normalized = normalize_relative_path(PROJECT_WRITE_LOCK_PATH)?; + let (parent_relative, file_name) = normalized + .rsplit_once('/') + .ok_or_else(|| "项目写锁路径必须包含安全父目录".to_string())?; + let parent = resolve_local_project_path(root, parent_relative)?; + // create_new is the authority for the final lock component. On Windows a + // delete-pending lock can make a metadata preflight fail with ACCESS_DENIED + // before the existing bounded contention wait has a chance to run. + Ok(parent.join(file_name)) +} + +pub(crate) fn acquire_project_write_lock( + root: &Path, + command_id: &str, +) -> Result { + acquire_project_write_lock_failure(root, command_id).map_err(|failure| failure.message()) +} + +/// 与 `acquire_project_write_lock` 同一实现,但把失败分类交给调用方。 +/// +/// 有界等待必须按失败类型决定是否重试:用错误文案前缀做控制流时,改一次文案就等于改一次 +/// 重试语义。前置校验失败没有可重试语义,统一作为终态文案上报。 +pub(crate) fn acquire_project_write_lock_failure( + root: &Path, + command_id: &str, +) -> Result { + validate_project_root(root).map_err(ProjectWriteLockFailure::Terminal)?; + let mut path = + resolve_project_write_lock_path(root).map_err(ProjectWriteLockFailure::Terminal)?; + if let Some(parent) = path.parent() { + ensure_game_creator_private_directory_tree(parent, "项目锁目录") + .map_err(ProjectWriteLockFailure::Terminal)?; + prepare_game_creator_private_path_for_read(parent, true, "项目锁目录") + .map_err(ProjectWriteLockFailure::Terminal)?; + } + // Re-check the parent after creation so skipping metadata only for the final + // create_new target cannot weaken the normal ancestor link/reparse checks. + path = resolve_project_write_lock_path(root).map_err(ProjectWriteLockFailure::Terminal)?; + let payload = serde_json::json!({ + "commandId": command_id, + "pid": std::process::id(), + // 进程启动身份:崩溃残留锁要靠它区分“PID 被复用”和“持有者仍然活着”。 + "processStartedAt": project_write_lock_process_start_time_seconds(u64::from( + std::process::id() + )), + "createdAt": unix_timestamp(), + "nonce": PROJECT_WRITE_LOCK_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + }); + let content = serde_json::to_string_pretty(&payload) + .map_err(|error| format!("生成项目写锁失败:{error}")) + .map_err(ProjectWriteLockFailure::Terminal)?; + let mut retried_after_reclaim = false; + loop { + let mut options = fs::OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } + match options.open(&path) { + Ok(mut file) => { + if let Err(error) = file.write_all(content.as_bytes()) { + drop(file); + let _ = fs::remove_file(&path); + return Err(ProjectWriteLockFailure::Terminal(format!( + "写入项目写锁失败:{}: {error}", + path.display() + ))); + } + if let Err(error) = file.sync_all() { + drop(file); + let _ = fs::remove_file(&path); + return Err(ProjectWriteLockFailure::Terminal(format!( + "落盘项目写锁失败:{}: {error}", + path.display() + ))); + } + drop(file); + if let Err(error) = harden_new_game_creator_private_path(&path, false, "项目写锁") + { + let _ = fs::remove_file(&path); + return Err(ProjectWriteLockFailure::Terminal(error)); + } + let actual = match fs::read_to_string(&path) { + Ok(actual) => actual, + Err(error) => { + let _ = fs::remove_file(&path); + return Err(ProjectWriteLockFailure::Terminal(format!( + "读取项目写锁失败:{}: {error}", + path.display() + ))); + } + }; + if actual != content { + let _ = fs::remove_file(&path); + return Err(ProjectWriteLockFailure::Terminal(format!( + "项目写锁内容校验失败:{}", + path.display() + ))); + } + return Ok(ProjectWriteLock { + path, + content: content.clone(), + bypassed_same_process: false, + }); + } + Err(error) => { + // 是否可重试只看错误码(平台判据见 `project_write_lock_open_failure_for`): + // 拿 `path.exists()` 当场判死会在删除拆链窗口里把瞬时争用变成永久失败。 + let failure = + project_write_lock_open_failure_for(PROJECT_WRITE_LOCK_PLATFORM, &error); + if failure == ProjectWriteLockOpenFailure::Permission { + // 只有 Unix 的 EACCES 能在这里被明确判定(Windows 的 ACCESS_DENIED + // 归可重试,终态由等待方在预算耗尽后投影)。权限拒绝不会重试,所以在 + // 这里记录:它必须能在 App 日志里和"别人正在写"区分开。 + app_log!( + "project.write_lock.permission_denied commandId={command_id} path={} osError={:?}", + path.display(), + error.raw_os_error() + ); + return Err(ProjectWriteLockFailure::Terminal( + project_write_lock_permission_error(&path, &error), + )); + } + if failure == ProjectWriteLockOpenFailure::Other { + return Err(ProjectWriteLockFailure::Terminal(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) + .map_err(ProjectWriteLockFailure::Terminal)? + { + app_log!( + "project.write_lock.reclaim_stale commandId={command_id} path={} holder={}", + path.display(), + snapshot.describe_holder() + ); + retried_after_reclaim = true; + continue; + } + } + } + if crate::agent::autonomous_game_build_root_run_active_at(root) + && project_write_lock_is_owned_by_current_process(&path) + { + // The autonomous game-build lane intentionally permits + // parallel specialist actions. If the durable lock belongs + // to this very process, contention is an in-process overlap, + // not another application editing the project. Return an + // advisory guard and leave the real lock untouched. + return Ok(ProjectWriteLock { + path, + content: String::new(), + bypassed_same_process: true, + }); + } + // 争用不在零等待入口里记日志:有界等待会把这个函数调用上千次, + // 每次记一行会淹掉日志。等待方在预算耗尽时记一条带等待时长的记录。 + return Err(ProjectWriteLockFailure::Retryable { + path: path.clone(), + source: error, + }); + } + } + } +} diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 4095ab9cd..d29d54561 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -27,7 +27,7 @@ - 背景:#211 要求 sidecar 满足当前用户独占、禁止继承的 DACL。新建文件会先继承父目录 ACE,生产路径把这种短暂不合格送进 UAC;`project.lock` 还在独占句柄上 harden。含空格项目路径上提权 ArgumentList 被拆开,修复以 exit 1 失败。GDD 审批改意见因此弹权限,V1 锁创建不会。 - 决策:`harden_new_game_creator_private_path` 只在本进程收紧 owner/DACL,失败则删除刚创建的对象,不 UAC 接管。项目锁先写再释放句柄再 harden,并用内容回读防换绑;UAC 仍只用于允许范围内的已有外人本对象。提权 helper 的 ArgumentList 改为一条按 Windows 规则加引号的字符串。 -- 影响范围:`config.rs` 的新建 harden 与提权命令行、`filesystem.rs` 的项目锁创建;不改变锁竞争、失效回收、Drop 删除,也不放宽 symlink / reparse / 外人本 fail-closed。 +- 影响范围:`config.rs` 的新建 harden 与提权命令行、`project/write_lock.rs` 的项目锁创建;不改变锁竞争、失效回收、Drop 删除,也不放宽 symlink / reparse / 外人本 fail-closed。 - 验证方式:Windows 定向测试覆盖 `Genarrative GameAgent\gameagent-*` 取锁与私有 DACL,以及带空格路径的 quoted ArgumentList。 - 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、`docs/project-memory/shared-memory/pitfalls.md`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index d5978358b..05cf7b501 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5058,7 +5058,7 @@ - 原因:fixture 用 `0xFFFF_FFF0` 当死 PID;Unix 的 `pid_t` 是有符号 32 位,`i32::try_from` 直接失败,存活判定返回 `None`(无法判定)而不是 `Some(false)`,于是落回 600 秒保守分支,残留锁不再被回收。 - 处理:实现层把“平台不可能分配出的进程号”(0 或超出平台 pid 宽度)判为持有者不存在并直接回收;fixture 改用 `i32::MAX as u64 - 1`,另加 `u64::MAX` 非法进程号用例。 - 验证:WSL Ubuntu 上 `cargo test --bin genarrative-ai-game-creator-shell project_lock_recovery` 7 条全过;Windows 上把可表示性判据临时回退到 HEAD 后,只有 `project_write_lock_reclaims_unrepresentable_owner_pid` 失败,说明该用例确实覆盖这条分支;Linux CI 的原始失败记录覆盖越界 PID 分支。 -- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs`、`apps/ai-game-creator-shell/src-tauri/src/tests/project_lock_recovery.rs`。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs`、`apps/ai-game-creator-shell/src-tauri/src/tests/project_lock_recovery.rs`。 ## 锁文件回收的判定与删除必须基于同一份快照(2026-09-09) @@ -5066,7 +5066,7 @@ - 原因:`project_write_lock_can_be_reclaimed` 只是快照观察,调用方拿到 true 后无条件 unlink;helper 还分别重读 `createdAt` / `pid` / `processStartedAt`,并发替换会拼出“旧 inode 的死 PID + 新 inode 的启动身份”。 - 处理: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`。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs`。 ## 2026-09-10 Direct 写通道零等待取锁把毫秒级竞争放大成整轮阻断 @@ -5076,4 +5076,4 @@ - **补充:重试性不能由一次 metadata 观察决定**。Windows 上 `create_new` 在目标被删除的拆链窗口里会返回 `ACCESS_DENIED(5)`,而此刻 `exists()` 往往已经报 false——本机 6 万次建锁 / 删锁竞争实测 396-538 例命中“5 + 目标不可见”。用 `path.exists()` 当场判成权限拒绝,等待层会立刻失败关闭,把同一个问题换成更误导的 ACL 文案。正确形状是:重试性只看错误码(`ACCESS_DENIED(5)` / sharing violation(32) / lock violation(33) / 已存在都可重试),终态改判放到等满预算之后——真的等过、目标此刻仍不存在,才改判成权限拒绝。分类判据把平台作为参数传入,Linux CI 才能覆盖 Windows 分支(CI 没有 Windows runner,`#[cfg(windows)]` 用例在 CI 里一次都不跑)。 - **排查顺序**:① 先看失败耗时——几十毫秒说明该入口没等,是等待窗口缺失,不是锁没释放。② 看错误里的 `ownerIsSelf`:`true` 指向同进程另一条写通道,`false` 指向外部进程;该字段只比 PID,PID 复用会把外人报成自己人,只当线索、不当判据(回收判据另有 `processStartedAt` 兜底)。③ **锁文件在失败后通常已被 Drop 删掉,现场缺文件不否定争用**;同理 `agc_list_registered_assets` 的 `pendingOperations: []` 只表示没有在跑的付费生成,与项目写锁无关,不构成“锁没有持有者”的证据。④ `.agent/.manifest.json.lock` 是 manifest 的持久 OS 文件锁(Windows 不共享写句柄 / Unix `flock`),0 字节长期存在是设计如此,不是残留锁,也不要用项目写锁的回收判据去处理它。⑤ 看到“项目写锁路径权限被拒绝”时注意它的含义:这是**等满等待窗口后**的终态改判(Windows 上真实 ACL 拒绝就走这条路),不是某一瞬间的 metadata 观察;反过来,`项目正在被其他写操作占用:…(持锁方身份不可读:锁文件此刻不存在…)` 是零等待入口无法区分拆链窗口与 ACL 拒绝时的并列表述,两者不要互相否定。 - **验证**:Rust 定向覆盖同进程重叠写等待、同轮并行写、活外部进程持锁带身份、权限拒绝不投影成争用,以及两条平台无关判据用例(重试性只由错误码决定、终态改判三条件);`runtime_project_write_lock_waits_for_delete_pending_target` 继续覆盖带句柄的 delete-pending 必须等到成功。 -- **关联**:`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`。 +- **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs`、`apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs`。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index cc9458b19..cfb01e78f 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1348,4 +1348,4 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过 - 复用 2026-09-09 的回收机制,不新增第二套:`ProjectWriteLockSnapshot` 补 `commandId` 与 `describe_holder()`,争用错误、`project.write_lock.reclaim_stale`、`project.write_lock.wait_exhausted` 三处共用同一份身份描述。“活持有者始终不回收”的判据不变。 - 等待预算耗尽时按 `project.write_lock.wait_exhausted` 记录 `commandId`、尝试次数、等待毫秒数、`projection=`(contention / permission_denied)与持锁方身份,Unix 上明确判定的权限拒绝按 `project.write_lock.permission_denied` 记录;争用不在零等待入口里逐次记账,避免有界等待的上千次重试淹没日志。这条日志正是 Issue #318 现场缺的“谁在持锁、是不是自己人”。 - 定向验收覆盖:同进程重叠写等待后成功、同一轮并行写多个文件、活外部进程持锁(错误带 `ownerIsSelf=false` 且锁文件不被回收)、ACL 拒绝不投影成争用,外加两条平台无关判据用例(重试性只由错误码决定、终态改判三条件)——后两条让 Linux CI 也能盯住 Windows 分支。对应 `project_lock_recovery`、`direct_tool_bridge` 与 `project/filesystem` 定向测试;`tests/project_tools.rs` 既有的 `runtime_project_write_lock_waits_for_delete_pending_target` 继续覆盖“带句柄的 delete-pending 必须等到成功”。 -- 仍待收口(后续事项):① 其余仍用零等待取锁的入口(`command.exec / project.verify / memory / conversation / task / checkpoint / 预览 / UI 编辑器 / 资源编辑器 / Tauri 命令`)本批不改,遇到同类争用仍会立刻失败;零等待入口无法区分“拆链窗口 / ACL 拒绝”,因此在前缀不变的前提下补一句“锁文件此刻不存在,可能是删除挂起、删除拆链窗口或权限 / ACL 拒绝”。② `project/filesystem.rs` 已经堆了回收、分类、诊断三套锁策略(1533 行),`direct_tool_bridge.rs` 也超过 3000 行,后续把锁策略拆到 `project/write_lock.rs`、Direct 锁用例移到 `tests/project_lock_recovery.rs`。③ 行为级 Windows 用例(delete-pending 等)仍只在 Windows 本地执行,CI 没有 Windows runner;关键判据已参数化到 Linux 可覆盖,行为级覆盖仍需本地执行或后续补 runner。 +- 仍待收口(后续事项):① 其余仍用零等待取锁的入口(`command.exec / project.verify / memory / conversation / task / checkpoint / 预览 / UI 编辑器 / 资源编辑器 / Tauri 命令`)本批不改,遇到同类争用仍会立刻失败;零等待入口无法区分“拆链窗口 / ACL 拒绝”,因此在前缀不变的前提下补一句“锁文件此刻不存在,可能是删除挂起、删除拆链窗口或权限 / ACL 拒绝”。② 锁策略已按“单一职责”收口到 `project/write_lock.rs`(856 行:取锁、等待分类、持锁方诊断、残留回收),`project/filesystem.rs` 回到项目文件 IO(680 行);仍待收口的是 Direct 锁用例,它们还留在 `direct_tool_bridge.rs`(3054 行,锁用例与 120 行桥实现混在一起),后续移到 `tests/project_lock_recovery.rs` 或独立测试文件。③ 行为级 Windows 用例(delete-pending 等)仍只在 Windows 本地执行,CI 没有 Windows runner;关键判据已参数化到 Linux 可覆盖,行为级覆盖仍需本地执行或后续补 runner。 diff --git a/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md b/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md index 48a74dd94..5f231d263 100644 --- a/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md +++ b/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md @@ -1896,7 +1896,7 @@ M0 文档 PR 本身最低验证:Markdown 结构与三张 Mermaid 图可解析 | 现役 pending wire | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs:26-27`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs:3-46,219-270`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs:290-520` | 保持 `game-creator-pending-action.v5`;M1 另建 planning pending,不升级全局 wire | | submit 专用提交/恢复分支 | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs` | **2026-08-14 按 M1B-2 收口**:在普通 dispatch 前处理 Runtime-owned submit;提交后终止策划子 run,并保留 generic v5 standalone pending + v4 batch anchors,不复用 `WaitingForUserInput`,不创建 planning pending。审批等待属于 M1C-1 | | JSON sidecar | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/json_sidecar.rs:44-104,122-250` | 现有 writer 可覆盖;不可变文件必须新增 no-replace helper | -| 项目锁 | `apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs:85-138`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs:1467-1505` | 所有 planning mutation 在同一项目锁内重读事实 | +| 项目锁 | `apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs:1467-1505` | 所有 planning mutation 在同一项目锁内重读事实 | | completion blocker | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs` | **M1C-1 隔离 worktree 已补齐**:仅对 exact `project-supervisor-plan` standard 顶层根 Run 生效,读取 GDD lineage、approval pending/receipt、generic submit anchors、terminal observation、decision audit、planning session 与 recovery,且只读不创建 pending;现役 collaboration blocker 对策划子 Agent 仍不适用 | | plan 根 run 子 Agent 创建面 | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs`(`observe_agent_runtime_agent_delegate` / `observe_agent_runtime_agent_spawn_isolated`)、`agent/runtime_protocol/run_configuration.rs`(`validate_project_supervisor_plan_root_binding_at`)、`agent/prompt.rs`(`game_creator_project_supervisor_tool_plan_prompt`) | **2026-08-14 `M1A-4` 已落地**:plan 根 run 只能委派 `project-planning`,`agent.spawn_isolated` 一律拒,两条通道共用 typed `kind=plan-root-child-target-unsupported`;plan source 下不拼 `supervisorIntro` 与 `$visualContract`。**已知残留(有意保留,见 decision-log 2026-08-14 `M1A-4` 条)**:`$base` 的 `$isolatedAgentTemplates` 段仍会向 plan 根 run 列出全部专业角色名——那是 `agent.spawn_isolated` 的模板目录,因执行层硬拒而成为死文本;因此**不得**写「plan 根 run 上下文不出现其它 Agent 名」这类验收句 | | plan retry | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/lifecycle_control.rs`(`resolve_game_creator_agent_runtime_retry_configuration_at`)、`runtime_driver.rs`(`supervisor_plan_root_identity_holds_at`) | **2026-08-13 `M1A-3` 已落地 source 保源**:`task.source == project-supervisor-plan` 时先走强判据,通过则保留该 source,失败 `kind=plan-root-retry-identity-unsupported`、不降级。gui/cli 仍走 `agent-background-task`。plan-session revision / `gddId` / 按 `gdd-approval` kind 禁 retry 仍属后续包(现役已拒 `waiting-*`) |