Merge branch 'master' into design_agent_refactor
Project CI / Repository checks (pull_request) Successful in 3m43s
Project CI / Frontend tests (pull_request) Successful in 4m37s
Project CI / Backend tests (pull_request) Successful in 7m30s
Project CI / Native shell tests (pull_request) Successful in 18m45s

This commit is contained in:
2026-09-10 23:50:49 +08:00
26 changed files with 1262 additions and 3429 deletions
@@ -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")
@@ -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();
}