修复 DirectProject 写入锁竞争并开启 Cocos 编辑器执行

DirectProject 历史落盘与 Codex 返回记录改用项目写锁有界等待,不再零等待失败拖垮整轮

Codex 返回记录取锁改到阻塞线程池执行,避免占用 runtime worker

agc_write_file 增加取锁/写入/revision 分段计时日志,用于定位长时间持锁

Windows dev 构建默认启用 cocos-editor-execute,使 Agent 具备 agc_cocos_execute

补充 start-tauri-dev 参数单测与历史落盘等待回归用例
This commit is contained in:
2026-09-11 23:25:37 +08:00
parent 299877b197
commit fd904ea144
5 changed files with 133 additions and 4 deletions
@@ -43,6 +43,28 @@ function buildTauriArguments(argv, devUrl = readAgcDevEndpoint().url) {
];
}
// `agc_cocos_execute` 与 Cocos 编辑器适配器只在 `cocos-editor-execute` feature 下
// 注册。开发构建默认在 Windows 打开它,否则 Agent 的工具清单里根本没有该工具,
// 只能退化成改写脚本。可用 AGC_DEV_CARGO_FEATURES(逗号分隔)覆盖,传空串即关闭。
function readDevCargoFeatures(env = process.env) {
const override = env.AGC_DEV_CARGO_FEATURES;
if (override !== undefined) {
return override
.split(',')
.map((value) => value.trim())
.filter(Boolean);
}
return process.platform === 'win32' ? ['cocos-editor-execute'] : [];
}
function withDevCargoFeatures(argv, features = readDevCargoFeatures()) {
if (features.length === 0) return argv;
if (argv.some((value) => value === '--features' || value === '-f')) {
return argv;
}
return [`--features=${features.join(',')}`, ...argv];
}
function spawnTauriCli(argv, { env = process.env } = {}) {
return spawnChild(process.execPath, [tauriCliPath, ...argv], {
cwd: appRoot,
@@ -106,7 +128,10 @@ async function runTauriDev(
shutdownRequested.then(() => false),
]);
if (!prepared || shutdownSignal) return 1;
const tauriArguments = buildTauriArguments(argv, endpoint.url);
const tauriArguments = buildTauriArguments(
withDevCargoFeatures(argv),
endpoint.url,
);
child = spawnCli(tauriArguments, {
env: withAgcDevEndpointEnv(endpoint),
});
@@ -193,6 +218,7 @@ export {
isDirectModuleExecution,
runTauriDev,
spawnTauriCli,
withDevCargoFeatures,
};
if (isDirectModuleExecution()) {
@@ -1,3 +1,4 @@
use super::runtime_actions::acquire_game_creator_agent_runtime_project_write_lock_with_wait;
use crate::config::prepare_game_creator_private_path_for_read;
use crate::project::{
append_jsonl_line_unlocked, enforce_project_permission_policy, project_append_lock_for,
@@ -204,7 +205,15 @@ fn append_direct_project_history_item_at_with_user_policy(
if !allow_user_item && is_direct_project_codex_user_item(item) {
return Ok(());
}
let _project_lock = crate::project::acquire_project_write_lock(root, "conversation.write")?;
// DirectProject 历史与 `agc_write_file` 共用项目写锁。文件写入在锁内要跑 Windows
// 私有路径准备与原子替换,现场实测一次 2.6KB 写入占锁 5.5 秒;零等待取锁会让
// 流式历史落盘在写文件期间直接失败,并把整轮判成“项目正在被其他写操作占用”
// (持锁方 commandId=direct-codex.file.write、ownerIsSelf=true)。这里与其它写入口
// 保持同一档有界等待;主路径已在阻塞线程池中执行。
let _project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
"conversation.write",
)?;
let path = history_path(root);
let history_exists =
prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")?;
@@ -429,6 +438,50 @@ mod tests {
assert_eq!(items, vec![item]);
}
#[test]
fn append_waits_for_a_same_process_project_writer() {
// 用 canonical 临时根:Windows 上 `%TEMP%` 的 8.3 短路径会让私有路径所有者
// 校验把测试目录判成“不属于当前用户”。
let temp_root = std::env::temp_dir()
.canonicalize()
.unwrap_or_else(|_| std::env::temp_dir());
let root = temp_root.join(format!(
"genarrative-agc-history-wait-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|value| value.as_millis())
.unwrap_or_default()
));
std::fs::create_dir_all(&root).expect("create project root");
crate::init_local_game_project_at(&root, "history-wait", "历史等待").expect("init project");
// 模拟 `agc_write_file`:它持锁期间历史落盘必须排队等待,而不是零等待失败后
// 把整轮判成“项目正在被其他写操作占用”。
let holder = crate::project::acquire_project_write_lock(&root, "direct-codex.file.write")
.expect("hold project write lock");
let worker_root = root.clone();
let worker = std::thread::spawn(move || {
append_direct_project_history_item_at(
&worker_root,
&serde_json::json!({
"type": "message",
"role": "assistant",
"id": "waits-for-writer",
"content": [{"type": "output_text", "text": "排队等待"}]
}),
)
});
std::thread::sleep(std::time::Duration::from_millis(300));
drop(holder);
worker
.join()
.expect("append worker")
.expect("history append must wait for the writer");
let items = read_direct_project_history_items_at(&root).expect("read history");
assert_eq!(items.len(), 1);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn codex_user_echo_is_filtered_but_agc_user_message_is_persisted() {
let root = tempfile::tempdir().expect("temp project");
@@ -1481,12 +1481,28 @@ fn bridge_write_file(root: &Path, arguments: &Value) -> Value {
// 而 `file.write / file.patch / file.delete` 等写入口用的是约 10 秒有界等待。
// 这是用户直接触发、失败即整轮无法落盘的项目写入通道,必须和其它写入口同语义:
// 短暂重叠排队等成功,只有预算耗尽才报出带持锁方身份的错误。
let acquire_started = std::time::Instant::now();
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
"direct-codex.file.write",
)?;
let lock_wait_ms = acquire_started.elapsed().as_millis();
let write_started = std::time::Instant::now();
let written = write_local_project_file_at(root, &path, content)?;
let write_ms = write_started.elapsed().as_millis();
let revision_started = std::time::Instant::now();
let revision = advance_agent_runtime_project_revision_locked(root)?;
// 现场一次 2.6KB 写入实测 5.5 秒。只在明显偏慢时记账,正常写入不刷日志。
if lock_wait_ms + write_ms > 200 {
app_log!(
"direct.file.write.timing path={} bytes={} lockWaitMs={} writeMs={} revisionMs={}",
written.path,
content.len(),
lock_wait_ms,
write_ms,
revision_started.elapsed().as_millis()
);
}
Ok::<_, String>(json!({
"status": "completed",
"path": written.path,
@@ -1388,7 +1388,12 @@ fn external_mcp_record_response(root: &Path, arguments: &Value) -> Value {
true,
);
}
let _project_lock = match acquire_project_write_lock(root, "conversation.write") {
// 与 DirectProject 历史落盘同一档有界等待:`agc_write_file` 持锁期间可能持续数秒,
// 零等待取锁会让 Codex 返回记录直接丢失。调用方已把本函数放进阻塞线程池。
let _project_lock = match acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
"conversation.write",
) {
Ok(lock) => lock,
Err(error) => {
return mcp_tool_result(format!("项目对话锁不可用:{error}"), Vec::new(), true)
@@ -1622,7 +1627,22 @@ async fn handle_direct_tools_mcp_request(root: &Path, request: Value) -> Option<
let result = match tool {
"client.session.info" => external_mcp_session_info(root),
"conversation.record_codex_response" => {
external_mcp_record_response(root, &arguments)
// 取锁等待是同步轮询(最多约 10 秒),必须放到阻塞线程池,
// 否则会占住 runtime worker。
let journal_root = root.to_path_buf();
let journal_arguments = arguments.clone();
match tokio::task::spawn_blocking(move || {
external_mcp_record_response(&journal_root, &journal_arguments)
})
.await
{
Ok(result) => result,
Err(error) => mcp_tool_result(
format!("Codex 返回记录任务未返回:{error}"),
Vec::new(),
true,
),
}
}
"conversation.list" => external_mcp_conversation_list(root, &arguments),
"conversation.read" => external_mcp_conversation_read(root, &arguments),
@@ -13,6 +13,7 @@ import {
import {
buildTauriArguments,
runTauriDev as runTauriDevImpl,
withDevCargoFeatures,
} from '../scripts/start-tauri-dev.mjs';
const testEndpoint = {
@@ -40,6 +41,19 @@ async function waitForFile(path: string, timeoutMs = 5000) {
}
describe('AI 游戏创作 Tauri dev 启动参数', () => {
test('开发构建默认带上 Cocos 编辑器 feature', () => {
expect(
withDevCargoFeatures(['--no-watch'], ['cocos-editor-execute']),
).toEqual(['--features=cocos-editor-execute', '--no-watch']);
expect(withDevCargoFeatures(['--no-watch'], [])).toEqual(['--no-watch']);
expect(
withDevCargoFeatures(
['--features', 'custom-feature'],
['cocos-editor-execute'],
),
).toEqual(['--features', 'custom-feature']);
});
test('普通 dev 参数原样交给 Tauri CLI', () => {
expect(buildTauriArguments(['--no-watch'], testEndpoint.url)).toEqual([
'dev',