修复 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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user