修复 DirectProject 首回合 MCP 就绪竞态
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled

增加 DirectProject MCP 首轮可选启动等待配置

在首次 turn/start 前等待已启用第三方 MCP 的 app-server 状态

保留失败和取消 MCP 的非阻塞处理

同步 DirectProject MCP 扩展技术方案与系统提示行为

补充启动参数和隔离配置断言
This commit is contained in:
2026-09-01 05:01:46 +00:00
parent 6a091afc3b
commit 7731b3280f
3 changed files with 94 additions and 3 deletions
@@ -7,7 +7,7 @@ use std::process::Stdio;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, OnceLock, Weak};
use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::sync::{mpsc, oneshot, Mutex};
use tokio::sync::{mpsc, oneshot, Mutex, Notify};
const GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID: &str = "genarrative_agc";
const GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV: &str = "GENARRATIVE_AGC_CODEX_API_KEY";
@@ -28,6 +28,7 @@ const GAME_CREATOR_CODEX_APP_SERVER_RPC_TIMEOUT_MS: u64 = 30_000;
const DIRECT_PROJECT_IDLE_TIMEOUT_MS: u64 = 15 * 60 * 1_000;
const DIRECT_PROJECT_ACTIVE_MCP_TOOL_TIMEOUT_MS: u64 = 110 * 60 * 1_000;
const DIRECT_PROJECT_TURN_HARD_TIMEOUT_MS: u64 = 120 * 60 * 1_000;
const DIRECT_PROJECT_MCP_OPTIONAL_STARTUP_GRACE_MS: u64 = 120_000;
const DIRECT_CODEX_ACTIVITY_EMIT_MIN_INTERVAL: std::time::Duration =
std::time::Duration::from_millis(250);
const DIRECT_CODEX_SHELL_ENVIRONMENT_POLICY: &str = "shell_environment_policy.inherit=\"core\"";
@@ -568,6 +569,10 @@ struct CodexAppServerInner {
_working_dir: tempfile::TempDir,
workspace_path: std::path::PathBuf,
workspace_mode: CodexAppServerWorkspaceMode,
client_mcp_server_names: Vec<String>,
client_mcp_startup_statuses: Mutex<HashMap<String, String>>,
client_mcp_startup_notify: Notify,
initial_client_mcp_startup_waited: AtomicBool,
_provider_proxy: Option<CodexProviderProxy>,
tool_bridge: Option<DirectToolBridge>,
_skill_roots: Option<Vec<std::path::PathBuf>>,
@@ -1132,6 +1137,10 @@ fn configure_game_creator_codex_app_server_command_for_mode(
if workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
let current_executable = direct_tools_mcp_executable_path()?;
command
.arg("-c")
.arg(format!(
"mcp_optional_startup_grace_ms={DIRECT_PROJECT_MCP_OPTIONAL_STARTUP_GRACE_MS}"
))
.arg("-c")
.arg(format!(
"mcp_servers.agc_tools.command={}",
@@ -1333,6 +1342,10 @@ fn trust_isolated_game_creator_codex_workspace(
);
let mut root = toml::map::Map::new();
root.insert("projects".to_string(), toml::Value::Table(projects));
root.insert(
"mcp_optional_startup_grace_ms".to_string(),
toml::Value::Integer(DIRECT_PROJECT_MCP_OPTIONAL_STARTUP_GRACE_MS as i64),
);
if !client_mcp_servers.is_empty() {
let mut mcp_servers = toml::map::Map::new();
for server in client_mcp_servers {
@@ -1538,6 +1551,10 @@ impl CodexAppServerConnection {
} else {
Vec::new()
};
let client_mcp_server_names = client_mcp_servers
.iter()
.map(|server| server.name.clone())
.collect::<Vec<_>>();
if workspace_override.is_some() && workspace_mode.allows_workspace_writes() {
trust_isolated_game_creator_codex_workspace(
&isolated_codex_home,
@@ -1684,6 +1701,10 @@ impl CodexAppServerConnection {
_working_dir: working_dir,
workspace_path,
workspace_mode,
client_mcp_server_names,
client_mcp_startup_statuses: Mutex::new(HashMap::new()),
client_mcp_startup_notify: Notify::new(),
initial_client_mcp_startup_waited: AtomicBool::new(false),
_provider_proxy: provider_proxy,
tool_bridge,
_skill_roots: skill_roots,
@@ -1760,6 +1781,54 @@ impl CodexAppServerConnection {
.map_err(|error| format!("刷新 Codex app-server stdin 失败:{error}"))
}
async fn wait_for_initial_client_mcp_startup(&self) {
if self.inner.workspace_mode != CodexAppServerWorkspaceMode::DirectProject
|| self.inner.client_mcp_server_names.is_empty()
|| self
.inner
.initial_client_mcp_startup_waited
.swap(true, Ordering::AcqRel)
{
return;
}
let deadline = tokio::time::Instant::now()
+ std::time::Duration::from_millis(DIRECT_PROJECT_MCP_OPTIONAL_STARTUP_GRACE_MS);
loop {
let pending = {
let statuses = self.inner.client_mcp_startup_statuses.lock().await;
self.inner.client_mcp_server_names.iter().any(|name| {
!matches!(
statuses.get(name).map(String::as_str),
Some("ready") | Some("failed") | Some("cancelled")
)
})
};
if !pending {
return;
}
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
eprintln!(
"agent.direct_codex.client_mcp_startup_wait timed out after {}ms",
DIRECT_PROJECT_MCP_OPTIONAL_STARTUP_GRACE_MS
);
return;
}
tokio::select! {
_ = self.inner.client_mcp_startup_notify.notified() => {}
_ = tokio::time::sleep(remaining) => {
eprintln!(
"agent.direct_codex.client_mcp_startup_wait timed out after {}ms",
DIRECT_PROJECT_MCP_OPTIONAL_STARTUP_GRACE_MS
);
return;
}
}
}
}
async fn notify(&self, method: &str, params: serde_json::Value) -> Result<(), String> {
self.write_message(&serde_json::json!({
"method": method,
@@ -1981,6 +2050,7 @@ impl CodexAppServerConnection {
) -> Result<platform_llm::LlmRunResponse, platform_llm::LlmError> {
let _turn_guard = self.inner.turn_gate.lock().await;
let thread_lease = self.thread_for(snapshot, &request, llm).await?;
self.wait_for_initial_client_mcp_startup().await;
let thread_id = thread_lease.thread_id.clone();
let prompt = if self.inner.workspace_mode.uses_direct_conversation() {
direct_codex_user_prompt(&request)
@@ -2575,6 +2645,18 @@ async fn read_game_creator_codex_app_server_stdout(
.pointer("/params/status")
.and_then(serde_json::Value::as_str),
) {
if inner
.client_mcp_server_names
.iter()
.any(|server_name| server_name == name)
{
inner
.client_mcp_startup_statuses
.lock()
.await
.insert(name.to_string(), status.to_string());
inner.client_mcp_startup_notify.notify_waiters();
}
let _ = crate::client_extensions::record_client_mcp_startup_status(name, status);
}
continue;
@@ -3759,6 +3841,7 @@ mod tests {
assert!(joined.contains("mcp_servers.agc_tools.command="));
assert!(joined.contains(DIRECT_TOOLS_MCP_MODE_FLAG));
assert!(joined.contains("mcp_servers.agc_tools.required=true"));
assert!(joined.contains("mcp_optional_startup_grace_ms=120000"));
assert!(!joined.contains("mcp_servers={}"));
}
@@ -3792,6 +3875,10 @@ mod tests {
parsed["mcp_servers"]["plugin-search"]["required"].as_bool(),
Some(false)
);
assert_eq!(
parsed["mcp_optional_startup_grace_ms"].as_integer(),
Some(DIRECT_PROJECT_MCP_OPTIONAL_STARTUP_GRACE_MS as i64)
);
assert!(parsed["mcp_servers"].get("agc_tools").is_none());
}
@@ -10,7 +10,7 @@ const MAX_DIRECT_SYSTEM_PROMPT_CHARS: usize = 16 * 1024;
const MIN_DIRECT_CLIENT_TURN_ID_CHARS: usize = 6;
const MAX_DIRECT_CLIENT_TURN_ID_CHARS: usize = 160;
const DIRECT_TAONIER_IDENTITY_GUIDANCE: &str = "对外身份合同:你是“陶泥儿”,是 Genarrative 的游戏创作助手。用户询问你是谁、你的名称或能力时,以陶泥儿的身份回答;不要把 Codex、ChatGPT、OpenAI、模型、通用 AI 助手或内部执行智能体当作自己的名称或对外身份。Codex app-server 仅是客户端内部执行技术;只有用户明确询问底层实现时才可如实说明,同时仍以陶泥儿自称。";
const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同(仅说明项目边界,不是流程门槛):当前 Codex cwd 是用户选择的项目目录(工作区根),源码、素材、音效和其它资源按项目现有结构放置;先按需读取当前 cwd 下适用的 `AGENTS.md`、README 或项目说明,把它们当作项目规范参考。原生文件工具、patch 和命令参数使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`;如果 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径。调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文;不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。`../`、绝对路径、`.agent/`、`.git/`、密钥文件和 Runtime 控制面属于客户端边界,不能请求扩权或直接改写。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill,以及经审核的 `agc_tools` MCP。你可以按需选择这些能力:`agc_write_file` 写入代码、配置、资源依赖清单或说明文件;`agc_generate_image` 生成普通图片、角色图、视觉规范图(icon-spec)、UI 设计图或发布宣传图;`agc_edit_image` 修改已登记图片;`taonier_prepare_game_art` 准备完整游戏美术包及可用的 canonical 切片;`agc_list_registered_assets`、`agc_list_project_files`、`agc_list_account_assets`、`agc_import_account_assets` 用于发现和接入资源依赖;`agc_create_or_derive_resource` 用于视频、角色动画、音效或背景音乐;`agc_browser_playtest` 用于需要时的本地试玩观察;`agc_read_skill_resource` 用于按需读取审核 Skill。切图、资源依赖、规范图和试玩都只是可选工具提示,不要求调用、固定顺序或特定产物,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。";
const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同(仅说明项目边界,不是流程门槛):当前 Codex cwd 是用户选择的项目目录(工作区根),源码、素材、音效和其它资源按项目现有结构放置;先按需读取当前 cwd 下适用的 `AGENTS.md`、README 或项目说明,把它们当作项目规范参考。原生文件工具、patch 和命令参数使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`;如果 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径。调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文;不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。`../`、绝对路径、`.agent/`、`.git/`、密钥文件和 Runtime 控制面属于客户端边界,不能请求扩权或直接改写。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill、经客户端注入的 `agc_tools` MCP,以及客户端扩展列表中用户已启用的第三方 MCP。用户明确指定第三方 MCP Server 或工具时,先在当前可用工具中查找并直接调用;找不到时如实说明,不得伪造。你可以按需选择这些能力:`agc_write_file` 写入代码、配置、资源依赖清单或说明文件;`agc_generate_image` 生成普通图片、角色图、视觉规范图(icon-spec)、UI 设计图或发布宣传图;`agc_edit_image` 修改已登记图片;`taonier_prepare_game_art` 准备完整游戏美术包及可用的 canonical 切片;`agc_list_registered_assets`、`agc_list_project_files`、`agc_list_account_assets`、`agc_import_account_assets` 用于发现和接入资源依赖;`agc_create_or_derive_resource` 用于视频、角色动画、音效或背景音乐;`agc_browser_playtest` 用于需要时的本地试玩观察;`agc_read_skill_resource` 用于按需读取审核 Skill。切图、资源依赖、规范图和试玩都只是可选工具提示,不要求调用、固定顺序或特定产物,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。";
const DIRECT_CODEX_ART_SPEC_ASSET_PATH: &str = "assets/art-spec.png";
const DIRECT_CODEX_BACKGROUND_ASSET_PATH: &str = "assets/direct-game-background.png";
const DIRECT_CODEX_SPRITESHEET_ASSET_PATH: &str = "assets/art-spritesheet.png";
@@ -4362,6 +4362,8 @@ mod tests {
assert!(prompt.contains("不要等待 Supervisor"));
assert!(prompt.contains("提示词与技能"));
assert!(prompt.contains("AGC 工程合同(仅说明项目边界,不是流程门槛)"));
assert!(prompt.contains("客户端扩展列表中用户已启用的第三方 MCP"));
assert!(prompt.contains("用户明确指定第三方 MCP Server 或工具时"));
assert!(prompt.contains("先按需读取当前 cwd 下适用的 `AGENTS.md`"));
assert!(prompt.contains("agc_write_file"));
assert!(prompt.contains("content 必须是目标文件的完整原始 UTF-8 正文"));
@@ -326,7 +326,7 @@ MCP · 来自 my-plugin.zip 启动失败
每个 MCP Server 独立启用和禁用。相对路径按来源目录解析,原始 command、args、cwd、env 或 URL 按原生配置保留。
运行时只转换 Codex 原生能够直接使用的标准字段。STDIO 支持 `command / args / env / env_vars / cwd`HTTP 支持 `url / bearer_token_env_var / http_headers / env_http_headers`;两类都支持原生启动超时、工具超时和工具筛选字段。客户端启用状态是运行时权威,每个第三方项固定 `enabled=true``required=false`,并沿用 DirectProject 无逐工具弹窗的自动批准方式。第三方 MCP 配置写入本次隔离 `CODEX_HOME/config.toml`,避免配置中的环境值或静态 header 出现在进程命令行
运行时只转换 Codex 原生能够直接使用的标准字段。STDIO 支持 `command / args / env / env_vars / cwd`HTTP 支持 `url / bearer_token_env_var / http_headers / env_http_headers`;两类都支持原生启动超时、工具超时和工具筛选字段。客户端启用状态是运行时权威,每个第三方项固定 `enabled=true``required=false`,并沿用 DirectProject 无逐工具弹窗的自动批准方式。第三方 MCP 配置写入本次隔离 `CODEX_HOME/config.toml`DirectProject 启动命令同时以 `-c mcp_optional_startup_grace_ms=120000` 传入同一值,命令行 override 是 Codex 最终生效来源。DirectProject 在首次 `turn/start` 前消费 app-server 已有的 `mcpServer/startupStatus/updated` 通知,等待每个已启用第三方 MCP 进入 `ready``failed``cancelled`;最长等待 `120000` 毫秒,超时后继续对话。ready 的 MCP 进入首轮,失败或超时的 MCP 被跳过并记录状态。该配置只作用于本次 DirectProject 隔离运行,不修改用户全局 Codex 配置。DirectProject 系统提示会声明客户端扩展列表中已启用的第三方 MCP;用户明确指定 Server 或工具时,模型只在当前可用工具中查找,找不到则如实说明,不伪造结果
用户导入来源中的相对 `cwd` 按该 MCP 配置文件所在目录解析;未声明 `cwd` 的 STDIO Server 默认以该目录启动,从而保留脚本参数的原生相对路径语义。客户端不替用户猜测普通文件如何启动。
@@ -343,6 +343,8 @@ MCP · 来自 my-plugin.zip 启动失败
- 尽量跳过失败项,保留 AGC 内置 Skill 和 `agc_tools`
- 不自动重试、不启动后台修复服务、不删除原始内容。
DirectProject 首轮使用 Codex 已有的 Eager MCP 启动和 AGC 侧有界 readiness gate:所有已启用第三方 MCP 并行尝试启动,AGC 在首次 `turn/start` 前等待现有 app-server 状态通知,最多等待 `120000` 毫秒;不执行工具探测调用,也不新增外部 MCP 服务。第三方 MCP 仍保持 `required=false`,单项进入 `failed`/`cancelled` 或等待超时后,DirectProject 继续对话;后续回合可重新发现等待窗口结束后才 ready 的 MCP。
扩展集合的 fingerprint 纳入现有 DirectProject app-server pool key,扩展集合变化后不复用不匹配的旧运行实例。
## 9. 客户端接口