修复直连 Codex 长任务验收等待

扩大 DirectProject 基础、MCP 与整回合有界等待并统一工具超时

明确禁用 shell 时使用文件快照和结构化试玩证据,避免误报能力阻断

同步真实陶泥儿生图、幂等复用与双视口浏览器验收记录
This commit is contained in:
Git Hooks Test
2026-08-21 13:30:06 +08:00
parent bacd7a9da7
commit 9d73f9a157
3 changed files with 187 additions and 23 deletions
@@ -1,7 +1,7 @@
use super::*;
use platform_llm::LlmMessageRole;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::process::Stdio;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, OnceLock, Weak};
@@ -18,6 +18,9 @@ const GAME_CREATOR_CODEX_APP_SERVER_BACKLOG_TURN_MAX: usize = 128;
const GAME_CREATOR_CODEX_APP_SERVER_POOL_MAX: usize = 32;
const GAME_CREATOR_CODEX_APP_SERVER_THREAD_MAX: usize = 128;
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;
pub(in crate::agent) const GAME_CREATOR_CODEX_APP_SERVER_TERMINAL_UNKNOWN_PREFIX: &str =
"codex-app-server-terminal-unknown:";
pub(in crate::agent) const GAME_CREATOR_CODEX_APP_SERVER_ERROR_KIND_PREFIX: &str =
@@ -302,7 +305,10 @@ impl From<&AgentRuntimeProviderRequestSnapshot> for CodexNodeThreadKey {
#[derive(Clone, Debug)]
enum CodexTurnEvent {
AgentMessageDelta(String),
Item(serde_json::Value),
Item {
completed: bool,
params: serde_json::Value,
},
Terminal(serde_json::Value),
TransportClosed(String),
}
@@ -349,6 +355,58 @@ impl CodexAppServerWorkspaceMode {
}
}
fn update_active_direct_mcp_tool_calls(
active_tool_calls: &mut HashSet<String>,
completed: bool,
params: &serde_json::Value,
) {
if params
.pointer("/item/type")
.and_then(serde_json::Value::as_str)
!= Some("mcpToolCall")
{
return;
}
let item_id = params
.pointer("/item/id")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.is_empty())
.unwrap_or("mcp-tool-call-without-id")
.to_string();
if completed {
active_tool_calls.remove(&item_id);
} else {
active_tool_calls.insert(item_id);
}
}
fn game_creator_codex_app_server_idle_timeout_ms(
workspace_mode: CodexAppServerWorkspaceMode,
request_timeout_ms: u64,
has_active_mcp_tool: bool,
) -> u64 {
if workspace_mode != CodexAppServerWorkspaceMode::DirectProject {
return request_timeout_ms;
}
let direct_timeout_ms = if has_active_mcp_tool {
DIRECT_PROJECT_ACTIVE_MCP_TOOL_TIMEOUT_MS
} else {
DIRECT_PROJECT_IDLE_TIMEOUT_MS
};
request_timeout_ms.max(direct_timeout_ms)
}
fn game_creator_codex_app_server_hard_timeout_ms(
workspace_mode: CodexAppServerWorkspaceMode,
request_timeout_ms: u64,
) -> u64 {
if workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
request_timeout_ms.max(DIRECT_PROJECT_TURN_HARD_TIMEOUT_MS)
} else {
request_timeout_ms
}
}
struct CodexAppServerInner {
writer: Mutex<tokio::process::ChildStdin>,
child: Mutex<Option<tokio::process::Child>>,
@@ -761,7 +819,10 @@ fn configure_game_creator_codex_app_server_command_for_mode(
.arg("-c")
.arg("mcp_servers.agc_tools.startup_timeout_sec=20")
.arg("-c")
.arg("mcp_servers.agc_tools.tool_timeout_sec=1800");
.arg(format!(
"mcp_servers.agc_tools.tool_timeout_sec={}",
DIRECT_PROJECT_ACTIVE_MCP_TOOL_TIMEOUT_MS / 1_000
));
if tool_bridge.is_some() {
command.arg("-c").arg(format!(
"mcp_servers.agc_tools.env_vars={}",
@@ -1483,11 +1544,47 @@ impl CodexAppServerConnection {
.request_timeout_ms
.unwrap_or(GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS)
.max(1);
let hard_timeout_ms =
game_creator_codex_app_server_hard_timeout_ms(self.inner.workspace_mode, timeout_ms);
let hard_deadline =
tokio::time::Instant::now() + std::time::Duration::from_millis(hard_timeout_ms);
let collect = async {
let mut final_text = None;
let mut streamed_text = String::new();
let mut active_mcp_tool_calls = HashSet::new();
loop {
match receiver.recv().await {
let remaining =
hard_deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
return Err(isolate_game_creator_codex_app_server_terminal_unknown(
&self.inner,
"等待 turn/completed 超时(达到 DirectProject 硬上限)",
)
.await);
}
let idle_timeout_ms = game_creator_codex_app_server_idle_timeout_ms(
self.inner.workspace_mode,
timeout_ms,
!active_mcp_tool_calls.is_empty(),
);
let wait_timeout =
std::cmp::min(remaining, std::time::Duration::from_millis(idle_timeout_ms));
let event = match tokio::time::timeout(wait_timeout, receiver.recv()).await {
Ok(event) => event,
Err(_) => {
let reason = if active_mcp_tool_calls.is_empty() {
"等待 turn/completed 超时"
} else {
"等待 turn/completed 超时(MCP 工具仍在活动)"
};
return Err(isolate_game_creator_codex_app_server_terminal_unknown(
&self.inner,
reason,
)
.await);
}
};
match event {
Some(CodexTurnEvent::AgentMessageDelta(delta)) => {
streamed_text.push_str(&delta);
if let Some(callback) = on_agent_message_delta.as_deref_mut() {
@@ -1498,12 +1595,21 @@ impl CodexAppServerConnection {
});
}
}
Some(CodexTurnEvent::Item(params)) => {
Some(CodexTurnEvent::Item { completed, params }) => {
if let Some(item) = params.get("item") {
let item_type = item
.get("type")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
if self.inner.workspace_mode
== CodexAppServerWorkspaceMode::DirectProject
{
update_active_direct_mcp_tool_calls(
&mut active_mcp_tool_calls,
completed,
&params,
);
}
if item_type == "agentMessage" {
if let Some(text) = item
.get("text")
@@ -1581,18 +1687,7 @@ impl CodexAppServerConnection {
}
}
};
let text = match tokio::time::timeout(std::time::Duration::from_millis(timeout_ms), collect)
.await
{
Ok(result) => result?,
Err(_) => {
return Err(isolate_game_creator_codex_app_server_terminal_unknown(
&self.inner,
"等待 turn/completed 超时",
)
.await)
}
};
let text = collect.await?;
guard.armed = false;
self.inner.turns.lock().await.remove(&turn_id);
let response = parse_game_creator_codex_app_server_text(&text, &thread_id, &request)?;
@@ -1966,7 +2061,10 @@ async fn read_game_creator_codex_app_server_stdout(
};
CodexTurnEvent::AgentMessageDelta(delta.to_string())
}
"item/started" | "item/completed" => CodexTurnEvent::Item(params),
"item/started" | "item/completed" => CodexTurnEvent::Item {
completed: method == "item/completed",
params,
},
_ => CodexTurnEvent::Terminal(params),
};
let sender = if method == "turn/completed" {
@@ -2427,6 +2525,63 @@ mod tests {
assert!(!direct_codex_user_prompt(&request).contains("AGC 系统规则"));
}
#[test]
fn direct_project_uses_bounded_idle_and_active_mcp_windows() {
let request_timeout_ms = 180_000;
let mut active_tool_calls = HashSet::new();
assert_eq!(
game_creator_codex_app_server_idle_timeout_ms(
CodexAppServerWorkspaceMode::DirectProject,
request_timeout_ms,
false,
),
DIRECT_PROJECT_IDLE_TIMEOUT_MS
);
let started = serde_json::json!({
"item": {
"id": "taonier-tool-call",
"type": "mcpToolCall"
}
});
update_active_direct_mcp_tool_calls(&mut active_tool_calls, false, &started);
assert_eq!(active_tool_calls.len(), 1);
assert_eq!(
game_creator_codex_app_server_idle_timeout_ms(
CodexAppServerWorkspaceMode::DirectProject,
request_timeout_ms,
true,
),
DIRECT_PROJECT_ACTIVE_MCP_TOOL_TIMEOUT_MS
);
assert_eq!(
game_creator_codex_app_server_hard_timeout_ms(
CodexAppServerWorkspaceMode::DirectProject,
request_timeout_ms,
),
DIRECT_PROJECT_TURN_HARD_TIMEOUT_MS
);
assert_eq!(
game_creator_codex_app_server_idle_timeout_ms(
CodexAppServerWorkspaceMode::DirectHome,
request_timeout_ms,
true,
),
request_timeout_ms
);
update_active_direct_mcp_tool_calls(&mut active_tool_calls, true, &started);
assert!(active_tool_calls.is_empty());
assert_eq!(
game_creator_codex_app_server_idle_timeout_ms(
CodexAppServerWorkspaceMode::DirectProject,
request_timeout_ms,
false,
),
DIRECT_PROJECT_IDLE_TIMEOUT_MS
);
}
#[test]
fn direct_home_mode_is_read_only_and_rejects_non_passive_items() {
assert!(CodexAppServerWorkspaceMode::DirectHome.uses_direct_conversation());
@@ -2722,7 +2877,7 @@ mod tests {
assert!(
serialized.contains("mcp_servers.agc_tools.default_tools_approval_mode=\"approve\"")
);
assert!(serialized.contains("mcp_servers.agc_tools.tool_timeout_sec=1800"));
assert!(serialized.contains("mcp_servers.agc_tools.tool_timeout_sec=6600"));
assert!(!serialized.contains("bearer_token_env_var"));
assert!(!serialized.contains("tnr_sk_"));
@@ -10,7 +10,7 @@ const MAX_DIRECT_SYSTEM_PROMPT_CHARS: usize = 64 * 1024;
const MAX_DIRECT_HOME_ATTACHMENTS: usize = 8;
const MAX_DIRECT_HOME_ATTACHMENT_NAME_CHARS: usize = 160;
const MAX_DIRECT_HOME_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96;
const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同:当前工作目录是唯一项目根;`game/index.html` 是可运行入口,`game/style.css` 与 `game/game.js` 是标准游戏代码,`assets/` 保存项目媒体,`.agent/` 是客户端维护的持久状态。Codex 可以按用户意图读写游戏源码,但不得手改 `.agent` 中的 manifest、revision、版本、来源身份、生成账本或试玩回执。需要平台美术或真实浏览器试玩时,只调用审核 Skill 声明的 `agc_tools` 工具;工具不可用或失败时如实报告。文件真实变化后由客户端确定性投影资源和版本。";
const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同:当前工作目录是唯一项目根;`game/index.html` 是可运行入口,`game/style.css` 与 `game/game.js` 是标准游戏代码,`assets/` 保存项目媒体,`.agent/` 是客户端维护的持久状态。Codex 可以按用户意图读写游戏源码,但不得手改 `.agent` 中的 manifest、revision、版本、来源身份、生成账本或试玩回执。DirectProject 为无人值守安全边界,shell/unified_exec 和任意网络被禁用;客户端会在系统上下文提供有界的当前游戏文件快照,并通过 `agc_tools` 返回结构化试玩证据。原生文件读取能力未出现时,直接使用这些快照和工具证据,不要声称因此无法验收。需要平台美术或真实浏览器试玩时,只调用审核 Skill 声明的 `agc_tools` 工具;工具不可用或失败时如实报告。文件真实变化后由客户端确定性投影资源和版本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";
@@ -1146,7 +1146,7 @@ fn direct_browser_evidence_prompt(
),
};
format!(
"请在同一 Codex 会话中进行第 {attempt} 次自主验收。客户端只会把真实运行证据交给你,不能把未启动的浏览器、截图或结构化结果假装成已通过。\n\n[客户端最低完成证明]\n{completion_status}\n\n[结构化浏览器证据]\n{browser_status};代码文件相对指纹是否变化={output_changed}\n{}\n详细诊断与平台素材运行时观察:{}\n交互探针:{}\n证据文件留在项目 `.agent/runtime/direct-codex-browser-validation/` 下;如果原生文件工具能安全读取 PNG,可查看截图,否则只依据下列结构化数据,不要声称看过截图。\n\n请读取当前工作区的实际 `game/` 文件和可用 `assets/`,判断游戏是否真的可运行、画布是否有内容、素材是否实际进入画面、布局是否适合 desktop/mobile、主要交互是否有响应。若平台素材只作为旁侧缩略图或未观察到 Canvas/WebGL 实际渲染,请把已登记素材接入核心可玩画面后再回复;不得用纯色圆点、emoji 或 CSS 图形替代核心素材。发现任何问题就直接修改文件并再次自检;不要创建 Supervisor、child 或 harness。最终回复必须结构化列出:检查文件、真实启动/试玩动作、desktop/mobile 观察、平台素材实际使用、已修复问题、剩余风险。",
"请在同一 Codex 会话中进行第 {attempt} 次自主验收。客户端只会把真实运行证据交给你,不能把未启动的浏览器、截图或结构化结果假装成已通过。\n\n[客户端最低完成证明]\n{completion_status}\n\n[结构化浏览器证据]\n{browser_status};代码文件相对指纹是否变化={output_changed}\n{}\n详细诊断与平台素材运行时观察:{}\n交互探针:{}\n证据文件留在项目 `.agent/runtime/direct-codex-browser-validation/` 下;如果原生文件工具能安全读取 PNG,可查看截图,否则只依据下列结构化数据,不要声称看过截图。\n\n当前系统上下文已经附带客户端读取的有界 `game/index.html`、`game/style.css`、`game/game.js` 快照;DirectProject 禁用 shell/unified_exec 时,不要因为没有原生列目录或读取工具就宣称无法验收,也不要要求宿主恢复工具。结合这些快照和上面的结构化浏览器证据,判断游戏是否真的可运行、画布是否有内容、素材是否实际进入画面、布局是否适合 desktop/mobile、主要交互是否有响应。若平台素材只作为旁侧缩略图或未观察到 Canvas/WebGL 实际渲染,请把已登记素材接入核心可玩画面后再回复;不得用纯色圆点、emoji 或 CSS 图形替代核心素材。发现任何问题就直接修改文件并再次自检;不要创建 Supervisor、child 或 harness,也不要手改 `.agent` 或直接保存项目版本(客户端会在回合后确定性投影)。最终回复必须结构化列出:检查文件、真实启动/试玩动作、desktop/mobile 观察、平台素材实际使用、已修复问题、剩余风险。",
viewport_lines.join("\n"),
if details.is_empty() {
"无额外硬失败详情".to_string()
@@ -2136,6 +2136,8 @@ mod tests {
}
assert!(prompt.contains("agc_tools.taonier_prepare_game_art"));
assert!(prompt.contains("agc_tools.agc_browser_playtest"));
assert!(prompt.contains("客户端会在系统上下文提供有界的当前游戏文件快照"));
assert!(prompt.contains("Codex 不直接保存或伪造项目版本"));
assert!(
prompt.contains("普通对话(例如问候、日期或项目无关问题)直接正常回答且不触碰工作区")
);
@@ -2496,7 +2498,11 @@ mod tests {
"{prompt}"
);
assert!(
prompt.contains("请读取当前工作区的实际 `game/` 文件"),
prompt.contains("当前系统上下文已经附带客户端读取的有界 `game/index.html`"),
"{prompt}"
);
assert!(
prompt.contains("不要因为没有原生列目录或读取工具就宣称无法验收"),
"{prompt}"
);
}
@@ -359,4 +359,7 @@ DirectHome 继续禁用 MCP、命令和写入。DirectProject 仍禁用通用 sh
- 本地 `agc_tools` MCP 已实际暴露 `agc_read_skill_resource / taonier_prepare_game_art / agc_browser_playtest` 三项工具并固定自动审批;引用读取严格限制为清单内一层 Markdown。真实 `gpt-5.6-sol max` 回合已读取 `agc-project-structure` 的直接引用并正确返回路径边界。
- 浏览器和美术副作用由随机 loopback 工具桥回到客户端主进程;真实 Codex 工具调用已得到 desktop/mobile `readyState=complete`、整体 `passed=true` 与 2 张截图。普通“你好,今天多少号”真实回合只回答日期,游戏文件、manifest、revision 均未变化。
- 开发网关会在 API Key Responses 成功响应中附带 `X-Codex-*` ChatGPT 额度头;隔离 app-server 会把它误判为余额 0。Direct conversation 现经只接受 Bearer `/responses` 的 loopback 流式代理转发,并剥离该组账户头;真实回合从 `usage-limit-exceeded` 恢复为完成。旧 ToolHost 不经过此代理。
- 真实陶泥儿美术调用已到达本地 External v1,但现有唯一私有 Key 绑定 `127.0.0.1:8082` 且在当前恢复的本地数据库中已失效,服务端明确返回 401;未创建生成账本、operation 或素材,也未发生重复提交。按照凭据安全合同,客户端不会自动覆盖旧文件或创建第二把 Key。完成真实生图仍需用户在动作时确认撤销/轮换该本机开发者 Key,然后复跑同一 Codex 工具调用
- 2026-08-21 真实客户端复跑已由当前登录会话为所选服务端建立新的私有开发者 Key;旧失效文件只改名保留,不读取、不打印也不提交。Codex 经 `taonier_prepare_game_art` 成功生成并登记 `assets/art-spec.png``assets/direct-game-background.png``assets/art-spritesheet.png`,三项均带平台 Canvas 来源身份。首次回合中第三个 `art-spritesheet` operation 已以稳定幂等键受理;重启客户端后只恢复该 operation,随后再次调用完整美术包时仍只有同一账本和 operation,三个 PNG 时间戳不变、账户泥点不再下降,工具两次均返回 `status=completed`、3 个 `assetPaths`、3 张图片且无 warning
- 同一真实项目已由 Codex 把平台背景、规范图棋子和核心图集实际接入 `game/index.html / style.css / game.js`。真实 Chromium 报告 `passed=true`desktop/mobile 均为 `readyState=complete`、Canvas 非空、无 console error 与 exceptiondesktop 仅有非致命 `favicon.ico` 404。两张截图确认桌面和手机均完整显示甜点星球三消画面,且结构化运行时证据观察到平台图片进入渲染。
- 真实复跑同时暴露并修复两个收尾缺陷:DirectProject 不能沿用普通 LLM 的 180 秒整回合超时,现改为 15 分钟基础空闲窗口、MCP 工具活动期 110 分钟空闲窗口、整个 turn 120 分钟硬上限;DirectHome 与旧 ToolHost 继续保持原超时。系统提示词和浏览器整改回灌同时明确 shell/unified_exec 被安全禁用时应使用已注入的游戏文件快照与结构化证据,不得误报“没有读取工具所以无法验收”,也不得要求 Codex 直接保存 `.agent` 版本。定向回归为 Direct Runtime 35/35、Codex app-server 23/23(另 1 项真实账号测试按设计 ignored)。
- 最后一轮改后 GUI 复验在桌面控制被物理 Escape 中止后未继续自动操作;非 UI CLI 又因不继承 GUI 登录态而得到 `usage-limit-exceeded`。因此本节只把已落盘的真实生图、幂等复用和双视口浏览器证据记为已完成,不把改后最终聊天回复或新增项目版本伪报为已验收;下次从当前客户端发送普通项目消息即可复核新的等待窗口与证据回灌文案。