Merge branch 'master' into design_agent_refactor
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") {
|
||||
return "当前陶泥儿账号可能没有访问该资源的权限,请检查账号后重试";
|
||||
}
|
||||
if error.contains("项目正在被其他写操作占用") {
|
||||
if error.contains(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX) {
|
||||
return "当前项目仍有写入正在结束,请稍后再次发送该需求";
|
||||
}
|
||||
if error.contains("身份不唯一")
|
||||
|
||||
@@ -1473,7 +1473,14 @@ fn bridge_write_file(root: &Path, arguments: &Value) -> Value {
|
||||
return Err("工具参数 content 不能包含 NUL".to_string());
|
||||
}
|
||||
reject_command_output_wrapper(content)?;
|
||||
let _lock = acquire_project_write_lock(root, "direct-codex.file.write")?;
|
||||
// Direct 写入原本用零等待取锁:任何重叠都在 24-42ms 内直接被判成"别人正在写",
|
||||
// 而 `file.write / file.patch / file.delete` 等写入口用的是约 10 秒有界等待。
|
||||
// 这是用户直接触发、失败即整轮无法落盘的项目写入通道,必须和其它写入口同语义:
|
||||
// 短暂重叠排队等成功,只有预算耗尽才报出带持锁方身份的错误。
|
||||
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
root,
|
||||
"direct-codex.file.write",
|
||||
)?;
|
||||
let written = write_local_project_file_at(root, &path, content)?;
|
||||
let revision = advance_agent_runtime_project_revision_locked(root)?;
|
||||
Ok::<_, String>(json!({
|
||||
@@ -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> {
|
||||
let asset_id = asset.get("assetId").and_then(Value::as_str)?;
|
||||
if asset_id.trim().is_empty() {
|
||||
@@ -2307,7 +2335,9 @@ async fn handle_direct_tool_bridge(
|
||||
bridge_list_registered_assets(&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_import_account_assets" => {
|
||||
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]
|
||||
fn resource_request_uuid_is_stable_v4_and_domain_separated() {
|
||||
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
|
||||
/// `max_attempts` polls.
|
||||
///
|
||||
/// `项目正在被其他写操作占用:` is the one lock error that means
|
||||
/// "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
|
||||
/// full window, a poll that will run again shortly waits far less.
|
||||
/// 能不能等由 `ProjectWriteLockFailure` 的**类型**决定,不解析错误文案:只有可重试的
|
||||
/// 取锁失败才在这里等,权限拒绝和坏路径立刻返回。判据曾经是
|
||||
/// `项目正在被其他写操作占用:` 这个前缀,那等于把"要不要等"绑在中文文案上——
|
||||
/// 改一次文案就悄悄改掉一次重试语义。Callers pick the budget from what a lost race
|
||||
/// 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(
|
||||
root: &Path,
|
||||
command_id: &str,
|
||||
max_attempts: usize,
|
||||
) -> Result<ProjectWriteLock, String> {
|
||||
let max_attempts = max_attempts.max(1);
|
||||
let started_at = std::time::Instant::now();
|
||||
for attempt in 0..max_attempts {
|
||||
match acquire_project_write_lock(root, command_id) {
|
||||
Err(error)
|
||||
if error.starts_with("项目正在被其他写操作占用:")
|
||||
&& attempt + 1 < max_attempts =>
|
||||
{
|
||||
std::thread::sleep(AGENT_RUNTIME_PROJECT_WRITE_LOCK_RETRY_INTERVAL);
|
||||
let failure = match acquire_project_write_lock_failure(root, command_id) {
|
||||
Ok(lock) => return Ok(lock),
|
||||
Err(failure) if failure.is_retryable() => failure,
|
||||
Err(failure) => return Err(failure.message()),
|
||||
};
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ enum AutonomousManifestParentWakeReconciliationOutcome {
|
||||
|
||||
pub(crate) fn autonomous_manifest_parent_wake_error_is_transient(error: &str) -> bool {
|
||||
let normalized = error.to_ascii_lowercase();
|
||||
error.starts_with("项目正在被其他写操作占用:")
|
||||
error.starts_with(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX)
|
||||
|| error.contains("另一个程序正在使用此文件")
|
||||
|| normalized.contains("sharing violation")
|
||||
|| normalized.contains("lock violation")
|
||||
|
||||
+1
-1
@@ -1495,7 +1495,7 @@ pub(crate) fn hydrate_planning_session_v2(
|
||||
"planning.v2.hydrate",
|
||||
) {
|
||||
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)
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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> {
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/// 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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user