修复 DirectProject 首回合 MCP 就绪竞态
增加 DirectProject MCP 首轮可选启动等待配置 在首次 turn/start 前等待已启用第三方 MCP 的 app-server 状态 保留失败和取消 MCP 的非阻塞处理 同步 DirectProject MCP 扩展技术方案与系统提示行为 补充启动参数和隔离配置断言
This commit is contained in:
@@ -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. 客户端接口
|
||||
|
||||
Reference in New Issue
Block a user