Merge pull request '修复 AGC Direct 写通道项目锁零等待与持锁方不可诊断' (#320) from fix/issue-318-direct-write-lock-wait into master
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/320
This commit was merged in pull request #320.
This commit is contained in:
@@ -1776,7 +1776,7 @@ fn direct_codex_failure_recovery_hint(stage: DirectCodexFailureStage, error: &st
|
|||||||
if normalized.contains("permission-denied") || normalized.contains("http 403") {
|
if normalized.contains("permission-denied") || normalized.contains("http 403") {
|
||||||
return "当前陶泥儿账号可能没有访问该资源的权限,请检查账号后重试";
|
return "当前陶泥儿账号可能没有访问该资源的权限,请检查账号后重试";
|
||||||
}
|
}
|
||||||
if error.contains("项目正在被其他写操作占用") {
|
if error.contains(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX) {
|
||||||
return "当前项目仍有写入正在结束,请稍后再次发送该需求";
|
return "当前项目仍有写入正在结束,请稍后再次发送该需求";
|
||||||
}
|
}
|
||||||
if error.contains("身份不唯一")
|
if error.contains("身份不唯一")
|
||||||
|
|||||||
@@ -1473,7 +1473,14 @@ fn bridge_write_file(root: &Path, arguments: &Value) -> Value {
|
|||||||
return Err("工具参数 content 不能包含 NUL".to_string());
|
return Err("工具参数 content 不能包含 NUL".to_string());
|
||||||
}
|
}
|
||||||
reject_command_output_wrapper(content)?;
|
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 written = write_local_project_file_at(root, &path, content)?;
|
||||||
let revision = advance_agent_runtime_project_revision_locked(root)?;
|
let revision = advance_agent_runtime_project_revision_locked(root)?;
|
||||||
Ok::<_, String>(json!({
|
Ok::<_, String>(json!({
|
||||||
@@ -1493,6 +1500,27 @@ fn bridge_write_file(root: &Path, arguments: &Value) -> Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 项目写锁的有界等待是同步轮询(2_000 × 5ms,最多约 10 秒)。handler 是 async,
|
||||||
|
/// 直接在 handler 里走完整条写路径会占住一个 tokio worker:争用窗口内同一轮并行写多个
|
||||||
|
/// 文件时会有多个 worker 被占,而这条 bridge 与只读端点、UI 命令共享同一个 runtime,
|
||||||
|
/// 正是 Issue #318 现场"只读工具全部正常"这条诊断特征会被破坏的情形。
|
||||||
|
/// 因此整条写路径挪进阻塞线程池,等待语义与错误文案都不变。
|
||||||
|
async fn bridge_write_file_in_blocking_pool(root: PathBuf, arguments: Value) -> Value {
|
||||||
|
let task_root = root.clone();
|
||||||
|
match tokio::task::spawn_blocking(move || bridge_write_file(&task_root, &arguments)).await {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(error) => bridge_tool_result(
|
||||||
|
redact_agent_runtime_error(
|
||||||
|
&root,
|
||||||
|
&format!("agc_write_file 阻塞任务未返回:{error}"),
|
||||||
|
480,
|
||||||
|
),
|
||||||
|
Vec::new(),
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn bridge_safe_account_asset_projection(asset: &Value) -> Option<Value> {
|
fn bridge_safe_account_asset_projection(asset: &Value) -> Option<Value> {
|
||||||
let asset_id = asset.get("assetId").and_then(Value::as_str)?;
|
let asset_id = asset.get("assetId").and_then(Value::as_str)?;
|
||||||
if asset_id.trim().is_empty() {
|
if asset_id.trim().is_empty() {
|
||||||
@@ -2307,7 +2335,9 @@ async fn handle_direct_tool_bridge(
|
|||||||
bridge_list_registered_assets(&state.root, &request.arguments)
|
bridge_list_registered_assets(&state.root, &request.arguments)
|
||||||
}
|
}
|
||||||
"agc_list_project_files" => bridge_list_project_files(&state.root, &request.arguments),
|
"agc_list_project_files" => bridge_list_project_files(&state.root, &request.arguments),
|
||||||
"agc_write_file" => bridge_write_file(&state.root, &request.arguments),
|
"agc_write_file" => {
|
||||||
|
bridge_write_file_in_blocking_pool(state.root.clone(), request.arguments).await
|
||||||
|
}
|
||||||
"agc_list_account_assets" => bridge_list_account_assets(&state, &request.arguments).await,
|
"agc_list_account_assets" => bridge_list_account_assets(&state, &request.arguments).await,
|
||||||
"agc_import_account_assets" => {
|
"agc_import_account_assets" => {
|
||||||
bridge_import_account_assets(&state, &request.arguments).await
|
bridge_import_account_assets(&state, &request.arguments).await
|
||||||
@@ -2712,6 +2742,185 @@ 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 有界等待是同步轮询(最多约 10 秒),而 handler 是 async:等待必须挪到阻塞线程池,
|
||||||
|
/// 否则会占住 runtime worker。本用例用默认的 current_thread runtime——handler 一旦同步
|
||||||
|
/// 阻塞,同一 runtime 上的心跳任务就完全停摆,因此在写入等待期间检查心跳即可区分。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn bridge_write_file_waits_on_the_blocking_pool_instead_of_a_runtime_worker() {
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
let temporary = tempfile::tempdir().expect("create blocking pool write root");
|
||||||
|
init_local_game_project_at(temporary.path(), "direct-pool", "Direct 阻塞池测试")
|
||||||
|
.expect("initialize blocking pool write root");
|
||||||
|
let state = direct_tool_bridge_state(temporary.path().to_path_buf());
|
||||||
|
|
||||||
|
// 另一条写通道由 OS 线程持有项目写锁,不受本 runtime 影响。
|
||||||
|
let holder_root = temporary.path().to_path_buf();
|
||||||
|
let lock = acquire_project_write_lock(&holder_root, "concurrent-writer")
|
||||||
|
.expect("acquire the concurrent project writer");
|
||||||
|
let holder = std::thread::spawn(move || {
|
||||||
|
std::thread::sleep(Duration::from_millis(250));
|
||||||
|
drop(lock);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 心跳任务:只有 handler 让出 worker,它才可能在写入等待期间推进。
|
||||||
|
let heartbeat = Arc::new(AtomicBool::new(false));
|
||||||
|
let heartbeat_writer = Arc::clone(&heartbeat);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||||
|
heartbeat_writer.store(true, Ordering::SeqCst);
|
||||||
|
});
|
||||||
|
|
||||||
|
let response = handle_direct_tool_bridge(
|
||||||
|
axum::extract::State(state),
|
||||||
|
axum::Json(DirectToolBridgeRequest {
|
||||||
|
tool: "agc_write_file".to_string(),
|
||||||
|
arguments: json!({ "path": "game/blocking-pool.js", "content": "// pooled\n" }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.0;
|
||||||
|
|
||||||
|
holder.join().expect("join the concurrent project writer");
|
||||||
|
assert!(
|
||||||
|
heartbeat.load(Ordering::SeqCst),
|
||||||
|
"写入等待期间同一 runtime 的心跳任务停摆了:等待必须走阻塞线程池,不能占住 worker"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
response.get("isError").and_then(Value::as_bool),
|
||||||
|
Some(false),
|
||||||
|
"the pooled direct write must still wait out the holder: {response}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(temporary.path().join("game/blocking-pool.js"))
|
||||||
|
.expect("read pooled direct write"),
|
||||||
|
"// pooled\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]
|
#[test]
|
||||||
fn resource_request_uuid_is_stable_v4_and_domain_separated() {
|
fn resource_request_uuid_is_stable_v4_and_domain_separated() {
|
||||||
let operation = direct_resource_request_uuid("turn-1", "operation", "abc");
|
let operation = direct_resource_request_uuid("turn-1", "operation", "abc");
|
||||||
|
|||||||
@@ -1822,27 +1822,45 @@ const AGENT_RUNTIME_PROJECT_WRITE_LOCK_SHORT_WAIT_ATTEMPTS: usize = 200;
|
|||||||
/// Take the project write lock, riding out transient contention for at most
|
/// Take the project write lock, riding out transient contention for at most
|
||||||
/// `max_attempts` polls.
|
/// `max_attempts` polls.
|
||||||
///
|
///
|
||||||
/// `项目正在被其他写操作占用:` is the one lock error that means
|
/// 能不能等由 `ProjectWriteLockFailure` 的**类型**决定,不解析错误文案:只有可重试的
|
||||||
/// "nothing is broken, the current holder is mid-write" — every other variant
|
/// 取锁失败才在这里等,权限拒绝和坏路径立刻返回。判据曾经是
|
||||||
/// (a torn lock file, a denied path) is returned immediately. Callers pick the
|
/// `项目正在被其他写操作占用:` 这个前缀,那等于把"要不要等"绑在中文文案上——
|
||||||
/// budget from what a lost race costs them: a one-shot user intent waits out the
|
/// 改一次文案就悄悄改掉一次重试语义。Callers pick the budget from what a lost race
|
||||||
/// full window, a poll that will run again shortly waits far less.
|
/// costs them: a one-shot user intent waits out the full window, a poll that will run
|
||||||
|
/// again shortly waits far less.
|
||||||
fn acquire_game_creator_agent_runtime_project_write_lock_within(
|
fn acquire_game_creator_agent_runtime_project_write_lock_within(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
command_id: &str,
|
command_id: &str,
|
||||||
max_attempts: usize,
|
max_attempts: usize,
|
||||||
) -> Result<ProjectWriteLock, String> {
|
) -> Result<ProjectWriteLock, String> {
|
||||||
let max_attempts = max_attempts.max(1);
|
let max_attempts = max_attempts.max(1);
|
||||||
|
let started_at = std::time::Instant::now();
|
||||||
for attempt in 0..max_attempts {
|
for attempt in 0..max_attempts {
|
||||||
match acquire_project_write_lock(root, command_id) {
|
let failure = match acquire_project_write_lock_failure(root, command_id) {
|
||||||
Err(error)
|
Ok(lock) => return Ok(lock),
|
||||||
if error.starts_with("项目正在被其他写操作占用:")
|
Err(failure) if failure.is_retryable() => failure,
|
||||||
&& attempt + 1 < max_attempts =>
|
Err(failure) => return Err(failure.message()),
|
||||||
{
|
};
|
||||||
std::thread::sleep(AGENT_RUNTIME_PROJECT_WRITE_LOCK_RETRY_INTERVAL);
|
if attempt + 1 == max_attempts {
|
||||||
|
// 等待预算耗尽才记一条:争用本身可能重试上千次,逐次记账会淹掉日志。
|
||||||
|
// 这条记录回答的正是 Issue #318 现场缺的问题——"谁在持锁、是不是自己人",
|
||||||
|
// 以及等满预算之后这到底是争用还是权限拒绝(`projection=`)。
|
||||||
|
// 单次试探(max_attempts == 1,例如 hydrate 的 try_acquire_*)根本没有等待:
|
||||||
|
// 既不写 `wait_exhausted`(waitedMs≈0 会让"耗尽"这个词失去意义,而 hydrate
|
||||||
|
// 每次状态变化都会撞一次锁,会把它变成噪声),也不做终态改判。
|
||||||
|
let waited = max_attempts > 1;
|
||||||
|
let (projection, message) = failure.exhausted_projection(waited);
|
||||||
|
if waited {
|
||||||
|
app_log!(
|
||||||
|
"project.write_lock.wait_exhausted commandId={command_id} attempts={} waitedMs={} projection={projection} holder={}",
|
||||||
|
attempt + 1,
|
||||||
|
started_at.elapsed().as_millis(),
|
||||||
|
crate::project::project_write_lock_contention_diagnostic(root)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
result => return result,
|
return Err(message);
|
||||||
}
|
}
|
||||||
|
std::thread::sleep(AGENT_RUNTIME_PROJECT_WRITE_LOCK_RETRY_INTERVAL);
|
||||||
}
|
}
|
||||||
unreachable!("project write lock retry loop always returns")
|
unreachable!("project write lock retry loop always returns")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ enum AutonomousManifestParentWakeReconciliationOutcome {
|
|||||||
|
|
||||||
pub(crate) fn autonomous_manifest_parent_wake_error_is_transient(error: &str) -> bool {
|
pub(crate) fn autonomous_manifest_parent_wake_error_is_transient(error: &str) -> bool {
|
||||||
let normalized = error.to_ascii_lowercase();
|
let normalized = error.to_ascii_lowercase();
|
||||||
error.starts_with("项目正在被其他写操作占用:")
|
error.starts_with(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX)
|
||||||
|| error.contains("另一个程序正在使用此文件")
|
|| error.contains("另一个程序正在使用此文件")
|
||||||
|| normalized.contains("sharing violation")
|
|| normalized.contains("sharing violation")
|
||||||
|| normalized.contains("lock violation")
|
|| normalized.contains("lock violation")
|
||||||
|
|||||||
+1
-1
@@ -1495,7 +1495,7 @@ pub(crate) fn hydrate_planning_session_v2(
|
|||||||
"planning.v2.hydrate",
|
"planning.v2.hydrate",
|
||||||
) {
|
) {
|
||||||
Ok(lock) => lock,
|
Ok(lock) => lock,
|
||||||
Err(error) if error.starts_with("项目正在被其他写操作占用:") => {
|
Err(error) if error.starts_with(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX) => {
|
||||||
return Ok(None)
|
return Ok(None)
|
||||||
}
|
}
|
||||||
Err(error) => return Err(error),
|
Err(error) => return Err(error),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ mod resource_dependency_graph;
|
|||||||
mod resource_editor;
|
mod resource_editor;
|
||||||
mod resource_layout;
|
mod resource_layout;
|
||||||
mod verification;
|
mod verification;
|
||||||
|
mod write_lock;
|
||||||
|
|
||||||
pub(crate) use agent_db::*;
|
pub(crate) use agent_db::*;
|
||||||
pub(crate) use asset_canvas::*;
|
pub(crate) use asset_canvas::*;
|
||||||
@@ -30,3 +31,4 @@ pub(crate) use resource_dependency_graph::*;
|
|||||||
pub(crate) use resource_editor::*;
|
pub(crate) use resource_editor::*;
|
||||||
pub(crate) use resource_layout::*;
|
pub(crate) use resource_layout::*;
|
||||||
pub(crate) use verification::*;
|
pub(crate) use verification::*;
|
||||||
|
pub(crate) use write_lock::*;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use super::*;
|
|||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[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_MAX_RECORD_BYTES: usize = 1024 * 1024;
|
||||||
const AGENT_DB_ACTION_RECEIPT_RECORD_TYPE: &str = "agent.runtime.action_receipt";
|
const AGENT_DB_ACTION_RECEIPT_RECORD_TYPE: &str = "agent.runtime.action_receipt";
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use super::*;
|
|||||||
use super::filesystem::validate_portable_project_path_component;
|
use super::filesystem::validate_portable_project_path_component;
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
use super::filesystem::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT;
|
use super::write_lock::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT;
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
fn windows_regular_file_handle_identity(file: &File, label: &str) -> Result<(u32, u64), String> {
|
fn windows_regular_file_handle_identity(file: &File, label: &str) -> Result<(u32, u64), String> {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -356,3 +356,89 @@ fn project_write_lock_decision_keeps_lock_when_mtime_is_unknown() {
|
|||||||
);
|
);
|
||||||
fs::remove_dir_all(root).ok();
|
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 outcome = acquire_project_write_lock(&root, "file.write");
|
||||||
|
fs::set_permissions(&agent_directory, original).expect("恢复控制目录权限");
|
||||||
|
|
||||||
|
// CI 容器以 root 运行,0o500 目录照样可以创建文件;此时本用例的前提不成立,
|
||||||
|
// 直接跳过。分类判据本身另有不依赖 ACL 环境的纯函数用例覆盖。
|
||||||
|
let Err(error) = outcome else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!error.starts_with("项目正在被其他写操作占用:"),
|
||||||
|
"权限拒绝不得投影成写锁争用:{error}"
|
||||||
|
);
|
||||||
|
|
||||||
|
fs::remove_dir_all(root).ok();
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
|
|
||||||
- 背景:#211 要求 sidecar 满足当前用户独占、禁止继承的 DACL。新建文件会先继承父目录 ACE,生产路径把这种短暂不合格送进 UAC;`project.lock` 还在独占句柄上 harden。含空格项目路径上提权 ArgumentList 被拆开,修复以 exit 1 失败。GDD 审批改意见因此弹权限,V1 锁创建不会。
|
- 背景:#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 规则加引号的字符串。
|
- 决策:`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。
|
- 验证方式:Windows 定向测试覆盖 `Genarrative GameAgent\gameagent-*` 取锁与私有 DACL,以及带空格路径的 quoted ArgumentList。
|
||||||
- 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、`docs/project-memory/shared-memory/pitfalls.md`。
|
- 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、`docs/project-memory/shared-memory/pitfalls.md`。
|
||||||
|
|
||||||
@@ -8197,3 +8197,13 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
|
|||||||
- 边界:`agent-runner.lock` / `agent-runner.gui-owner.lock` 是 OS 独占句柄锁,进程退出即释放,残留文件不阻塞下次启动;不要把它们当成项目写锁的同类残留处理。
|
- 边界:`agent-runner.lock` / `agent-runner.gui-owner.lock` 是 OS 独占句柄锁,进程退出即释放,残留文件不阻塞下次启动;不要把它们当成项目写锁的同类残留处理。
|
||||||
- 边界:锁文件里的 PID 若超出平台进程号空间(Unix `pid_t` 是有符号 32 位、Windows 是 32 位,均恒大于 0),它不可能属于任何活进程,按“持有者不存在”直接回收,不再落回 600 秒保守分支。
|
- 边界:锁文件里的 PID 若超出平台进程号空间(Unix `pid_t` 是有符号 32 位、Windows 是 32 位,均恒大于 0),它不可能属于任何活进程,按“持有者不存在”直接回收,不再落回 600 秒保守分支。
|
||||||
- 验证:`project_lock_recovery` 11 条与 `diagnostic_log` 7 条定向测试通过,真实二进制双实例复现“第二个实例写 `startup.runner.owner-lock.failed` 并弹出可见提示”。
|
- 验证:`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`,与其它写入口同语义,同一轮并行写按同一把锁串行。①′ 这条等待是同步轮询(最多约 10 秒),handler 是 async,因此写路径经 `spawn_blocking` 走阻塞线程池:直接在 handler 里同步等待会占住 tokio worker,争用窗口内并行写多个文件时会波及共享同一 runtime 的只读端点与 UI 命令,破坏 Issue #318 现场“只读工具全部正常”的诊断特征。② 争用错误前缀逐字不变并追加持锁方身份;`create_new` 失败拆成可重试(进入有界等待)/ 权限拒绝(失败关闭,文案不含争用前缀)/ 其它三类,**重试性只看错误码**:Windows 的 `ACCESS_DENIED(5)` 与删除拆链窗口在错误码上不可区分,一律先按可重试处理,等满预算且目标仍不存在时才由 `exhausted_projection` 改判成权限拒绝(单次试探不改判);`sharing violation(32)` 与 `lock violation(33)` 恒定归可重试。③ 等待预算耗尽时按 `project.write_lock.wait_exhausted` 记录持锁方快照、等待时长与 `projection=`(contention / permission_denied),Unix 上明确判定的权限拒绝按 `project.write_lock.permission_denied` 记录;这条日志与终态改判都只在真的等过(`max_attempts > 1`)时发生,hydrate 的单次试探既不写日志也不改判。④ 重试与否改由类型决定:`acquire_project_write_lock_failure` 返回 `ProjectWriteLockFailure::{Retryable, Terminal}`,有界等待按 `is_retryable()` 分流,`acquire_project_write_lock` 只是它的文案包装;`PROJECT_WRITE_LOCK_CONTENTION_PREFIX` 同时收口 `provider_recovery.rs` / `planning_session_v2.rs` / `direct_runtime.rs` 三处手写文案。⑤ 平台判据的每一环都要带平台:终态改判也曾只比 `ErrorKind`,而 errno 5 在 Windows 是 `ACCESS_DENIED`、在 Linux 是 `EIO`,CI 直接把它判成 `contention`;现由 `Retryable { platform, path, source }` 携带平台。
|
||||||
|
- 为什么不按“目标是否存在”当场分类:目标被删除时目录项先消失、删除挂起随后才结束,`create_new` 会在这个拆链窗口里返回 `ACCESS_DENIED(5)` 而 `exists()` 已经报 false(本机 6 万次建锁 / 删锁竞争实测 396-538 例命中该组合)。按一次 metadata 观察判成权限拒绝,等待层会立刻失败关闭,等于把 Issue #318 的“毫秒级直接失败”换成更误导的 ACL 文案;这也是对 2026-08-13 已定口径“这些结果统一投影为争用并进入既有有界等待”的回归。
|
||||||
|
- 复用既有实现:回收判据沿用 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` 追加同进程重叠写等待、同轮并行写、有界等待不占 runtime worker(默认 `current_thread` runtime 加心跳任务,同步阻塞会立刻让心跳停摆)、ACL 拒绝不投影成争用四条;`project/write_lock` 追加“重试性只由错误码决定”与“终态改判三条件”两条平台无关用例(平台作参数传入,Linux CI 覆盖 Windows 分支)。Windows 本机定向结果:`project_write_lock` 18 条、`bridge_write_file` 4 条、`parent_wake` 16 条、`waits_across` 3 条全过;CI(`71e9ad313`)四个 job 全绿,其中 Native shell tests 的首轮失败正是第 ⑤ 条平台判据缺陷。
|
||||||
|
- 关联文档:`docs/project-memory/shared-memory/pitfalls.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、Issue #318。
|
||||||
|
|||||||
@@ -5058,7 +5058,7 @@
|
|||||||
- 原因:fixture 用 `0xFFFF_FFF0` 当死 PID;Unix 的 `pid_t` 是有符号 32 位,`i32::try_from` 直接失败,存活判定返回 `None`(无法判定)而不是 `Some(false)`,于是落回 600 秒保守分支,残留锁不再被回收。
|
- 原因: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` 非法进程号用例。
|
- 处理:实现层把“平台不可能分配出的进程号”(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 分支。
|
- 验证: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)
|
## 锁文件回收的判定与删除必须基于同一份快照(2026-09-09)
|
||||||
|
|
||||||
@@ -5066,4 +5066,15 @@
|
|||||||
- 原因:`project_write_lock_can_be_reclaimed` 只是快照观察,调用方拿到 true 后无条件 unlink;helper 还分别重读 `createdAt` / `pid` / `processStartedAt`,并发替换会拼出“旧 inode 的死 PID + 新 inode 的启动身份”。
|
- 原因:`project_write_lock_can_be_reclaimed` 只是快照观察,调用方拿到 true 后无条件 unlink;helper 还分别重读 `createdAt` / `pid` / `processStartedAt`,并发替换会拼出“旧 inode 的死 PID + 新 inode 的启动身份”。
|
||||||
- 处理:payload 只解析一次并连同字节一起快照;删除前重新核对字节,只有内容仍是判定时的内容才 unlink;文件已消失或被替换时返回 false 并重试 `create_new`,不报错。
|
- 处理:payload 只解析一次并连同字节一起快照;删除前重新核对字节,只有内容仍是判定时的内容才 unlink;文件已消失或被替换时返回 false 并重试 `create_new`,不报错。
|
||||||
- 补充:`project_write_lock_file_modified_seconds` 读不到 mtime 时不要返回 `0`——纪元 0 会被算成极大年龄,把保守判定反转成“立刻回收”,甚至把活持有者当 PID 复用抢走;要用 `Option` 区分“mtime 未知”和“mtime 等于纪元 0”。
|
- 补充:`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 写通道零等待取锁把毫秒级竞争放大成整轮阻断
|
||||||
|
|
||||||
|
- **现象**: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` 区分“自己人”和“别人”。
|
||||||
|
- **补充:重试性不能由一次 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 里一次都不跑)。**判据的每一环都要带平台**:只看 `ErrorKind` 的判据在 Linux 上会给出相反结论——errno 5 在 Windows 是 `ACCESS_DENIED`、在 Linux 是 `EIO`(`Uncategorized`)。所以“等满预算再改判成权限拒绝”这第二个判据也必须连同平台与原始错误码一起传,否则 Windows 侧的行为在 CI 上永远测不到(本批第一次推送就是 CI 抓到 `permission_denied` 被改判成 `contention`:判据只比了 `kind()`)。
|
||||||
|
- **补充:同步有界等待不能直接跑在 async handler 里**。这条等待是 2 000 × 5ms 的同步轮询(最多约 10 秒),而 `handle_direct_tool_bridge` 是 async:直接在 handler 里等待会占住一个 tokio worker,争用窗口内同一轮并行写多个文件时会有多个 worker 被占,而 bridge 与只读端点、UI 命令共享同一个 runtime——于是“只读工具全部正常”这条现场诊断特征会在争用窗口内失效,把排障引向错误方向(本次现场正是靠它判断“写锁没释放”的)。做法是把整条写路径挪进 `tokio::task::spawn_blocking`(仓库既有模式),等待语义与错误文案都不变;用例用默认 `current_thread` runtime 加心跳任务锁住这一点:handler 一旦同步阻塞,同一 runtime 上的心跳就完全停摆。**这类“零等待改成有界等待”的改动都要同时问一句:调用方是不是 async,等待窗口会不会占住执行器。**
|
||||||
|
- **排查顺序**:① 先看失败耗时——几十毫秒说明该入口没等,是等待窗口缺失,不是锁没释放。② 看错误里的 `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 worker、活外部进程持锁带身份、权限拒绝不投影成争用,以及两条平台无关判据用例(重试性只由错误码决定、终态改判三条件);`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/write_lock.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs`。
|
||||||
|
|||||||
@@ -1337,3 +1337,16 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过
|
|||||||
- 回收判据与删除必须基于同一次读到的锁文件快照:payload 只解析一次,删除前重新核对字节,只有内容仍是判定时的内容才 unlink;文件已消失或被替换时重试 `create_new`,不把并发回收当成错误。
|
- 回收判据与删除必须基于同一次读到的锁文件快照:payload 只解析一次,删除前重新核对字节,只有内容仍是判定时的内容才 unlink;文件已消失或被替换时重试 `create_new`,不把并发回收当成错误。
|
||||||
- 启动诊断日志改为 `StartupLogSlot`:优先用已经生效的配置目录(含 `--config-dir`),否则退到平台配置根(Windows APPDATA、macOS Application Support、其它平台 `XDG_CONFIG_HOME` / `~/.config`),成功后再切换到真实配置目录。`startup.*.failed` 与 `show_startup_error_dialog` 不再是死分支;日志路径未知时同样给出用户可见提示。Windows 启动失败恢复系统消息框并附诊断日志路径,其它平台写 stderr,同一进程只提示一次。
|
- 启动诊断日志改为 `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` 并弹出可见提示。
|
- 边界与验证:残留的 `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 一节“这些结果统一投影为争用并进入既有有界等待”的口径一致。**失败耗时是判据**:几十毫秒说明该入口没等,不是锁没释放。
|
||||||
|
- 这条等待是**同步轮询**(2_000 × 5ms,最多约 10 秒),而 `handle_direct_tool_bridge` 是 async handler:直接在 handler 里跑完整条写路径会占住一个 tokio worker,争用窗口内同一轮并行写多个文件时会有多个 worker 被占,而这条 bridge 与只读端点、UI 命令共享同一个 runtime——Issue #318 现场“只读工具全部正常”这条诊断特征会在争用窗口内失效。因此写路径经 `bridge_write_file_in_blocking_pool` 走 `tokio::task::spawn_blocking`(仓库既有模式,如 `codex_app_server.rs` 的 DirectProject 历史落盘),等待语义与错误文案不变;定向用例用默认 `current_thread` runtime 加心跳任务锁住“等待期间 runtime 仍在推进”。
|
||||||
|
- 争用错误必须带持锁方身份才可行动:`项目正在被其他写操作占用:<锁路径>(持锁方 commandId=<命令> pid=<进程> createdAt=<创建时间> ownerIsSelf=<是否本进程>)`。锁文件处于 delete-pending 或尚未写完时读不到身份,也必须显式表达成“不可读”,不得默认成“没有持锁方”。前缀逐字不变:`project_gates.rs`、`provider_recovery.rs`、`planning_session_v2.rs`、`direct_runtime.rs` 和前端 `App.tsx` 都按它把争用识别成可等待的瞬时状态;这句话已是 `crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX` 单一真源,四个站点不再各自手写中文。
|
||||||
|
- `create_new` 的失败必须分三类处置,不能再共用一句文案:可重试(目标已存在、Windows `sharing violation(32)` / `lock violation(33)` / `ACCESS_DENIED(5)`)进入有界等待;明确判定不是争用的权限 / ACL 拒绝(Unix `EACCES`)失败关闭且文案不含争用前缀;其它 I/O 错误原样上报。**重试性只能由错误码决定,不能用 `path.exists()` 这类一次 metadata 观察决定**:目标被删除时目录项先消失、删除挂起随后才结束,`create_new` 会在这个拆链窗口里返回 `ACCESS_DENIED(5)`,而 `exists()` 往往已经报 false(本机实测 6 万次建锁 / 删锁竞争里 396-538 例命中该组合)。按“目标不存在”当场判成权限拒绝,等待层就会立刻失败关闭——正是本次要消灭的“毫秒级直接失败”,只是换成更误导的 ACL 文案。平台判据以 `project_write_lock_open_failure_for(platform, error)` 保留、平台由参数传入而不是 `#[cfg]`:CI 只有 Linux runner,Windows 分支必须在 Linux 上也能断言。
|
||||||
|
- Windows 上真实 ACL 拒绝与删除拆链窗口在错误码上不可区分,所以终态改判放到**等待预算耗尽之后**:`ProjectWriteLockFailure::exhausted_projection(waited)` 只在“真的等过预算 + 目标此刻仍不存在 + 错误码是 `ACCESS_DENIED(5)`”三个条件同时成立时才投影成权限拒绝;单次试探(`max_attempts == 1`,例如 hydrate 的 `try_acquire_...`)没有等待证据,保持争用语义。代价是 Windows 上真实 ACL 拒绝会先等满等待窗口(约 10 秒)才报权限错误;Unix 的 `EACCES` 立即判定、不等待。
|
||||||
|
- 重试与否改由**类型**决定,不再解析错误文案:`acquire_project_write_lock_failure` 返回 `ProjectWriteLockFailure::{Retryable, Terminal}`,有界等待按 `is_retryable()` 分流,`acquire_project_write_lock` 只是它的文案包装。零等待入口前缀不变,只在“错误码不可区分且目标此刻不存在”时补一句“可能是删除挂起、删除拆链窗口或权限 / ACL 拒绝”,把两种处置都交给调用方,而不是替它猜一个。
|
||||||
|
- 复用 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 现场缺的“谁在持锁、是不是自己人”。**这条日志与终态改判都只在真的等过(`max_attempts > 1`)时发生**:单次试探(hydrate 的 `try_acquire_*`)不写 `wait_exhausted`(`waitedMs≈0` 会让“耗尽”失去意义,而 hydrate 每次状态变化都会撞一次锁,写成日志就是噪声),也不做终态改判。
|
||||||
|
- 定向验收覆盖:同进程重叠写等待后成功、同一轮并行写多个文件、有界等待不占 runtime worker(`current_thread` + 心跳任务)、活外部进程持锁(错误带 `ownerIsSelf=false` 且锁文件不被回收)、ACL 拒绝不投影成争用,外加两条平台无关判据用例(重试性只由错误码决定、终态改判三条件)——后两条让 Linux CI 也能盯住 Windows 分支。对应 `project_lock_recovery`、`direct_tool_bridge` 与 `project/write_lock` 定向测试;`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/write_lock.rs`(887 行:取锁、等待分类、持锁方诊断、残留回收),`project/filesystem.rs` 回到项目文件 IO(680 行);仍待收口的是 Direct 锁用例,它们还留在 `direct_tool_bridge.rs`(3135 行,锁用例与桥实现混在一起),后续移到 `tests/project_lock_recovery.rs` 或独立测试文件。③ 行为级 Windows 用例(delete-pending 等)仍只在 Windows 本地执行,CI 没有 Windows runner;关键判据已参数化到 Linux 可覆盖,行为级覆盖仍需本地执行或后续补 runner。
|
||||||
|
|||||||
@@ -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 |
|
| 现役 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 |
|
| 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 |
|
| 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 仍不适用 |
|
| 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 根 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-*`) |
|
| 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-*`) |
|
||||||
|
|||||||
Reference in New Issue
Block a user