From d4be3dd97a4088535096f79c4e80d69866e88fef Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:21:18 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=20Claude=20=E4=B8=8A?= =?UTF-8?q?=E6=B8=B8=E9=94=99=E8=AF=AF=E5=B1=95=E7=A4=BA=E4=B8=8E=20MCP=20?= =?UTF-8?q?=E5=B9=B6=E5=8F=91=E9=9A=94=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端保留 401/403/408/429/5xx 状态并补回归测试 按项目隔离 Claude MCP 注册表并防止旧回合误停新桥 更新 DirectProject 排障记录 --- .../src-tauri/src/agent/claude_code_cli.rs | 12 ++++-- .../src-tauri/src/agent/direct_tools_mcp.rs | 42 +++++++++++++------ .../chat/conversation/directTurnFailure.ts | 21 ++++++++-- .../tests/directTurnFailure.test.ts | 34 +++++++++++++++ docs/project-memory/shared-memory/pitfalls.md | 3 +- 5 files changed, 93 insertions(+), 19 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/directTurnFailure.test.ts diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs index a9e238e02..f0f01ad0e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs @@ -94,11 +94,14 @@ impl Drop for ActiveClaudeCodeTurnGuard { } } -struct ExternalMcpStopGuard; +struct ExternalMcpStopGuard { + root: PathBuf, + token: String, +} impl Drop for ExternalMcpStopGuard { fn drop(&mut self) { - stop_external_mcp_loopback(); + stop_external_mcp_loopback_for_root(&self.root, &self.token); } } @@ -963,7 +966,10 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at( let (mcp_url, mcp_token) = start_external_mcp_loopback_for_direct_turn(root, llm.web_search_enabled).await?; direct_turn_trace("claude-mcp-loopback-ready"); - let _mcp_guard = ExternalMcpStopGuard; + let _mcp_guard = ExternalMcpStopGuard { + root: root.to_path_buf(), + token: mcp_token.clone(), + }; let home = crate::project::resolve_local_project_path(root, ".agent/runtime/claude-code/home")?; std::fs::create_dir_all(&home) .map_err(|error| format!("创建 cc Direct 隔离目录失败:{error}"))?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 11275387c..abb2b4a79 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -35,12 +35,15 @@ const EXTERNAL_MCP_RESPONSE_SUMMARY_MAX_CHARS: usize = 240; const EXTERNAL_MCP_JOURNAL_MAX_BYTES: u64 = 8 * 1024 * 1024; const EXTERNAL_MCP_JOURNAL_RELATIVE_PATH: &str = ".agent/conversations/codex-responses.jsonl"; static EXTERNAL_MCP_JOURNAL_LOCK: OnceLock> = OnceLock::new(); -static EXTERNAL_MCP_SERVER: OnceLock>> = OnceLock::new(); +static EXTERNAL_MCP_SERVER: OnceLock< + Mutex>, +> = OnceLock::new(); tokio::task_local! { static EXTERNAL_MCP_BRIDGE_URL: String; } pub(crate) struct ExternalMcpServer { + token: String, _direct_turn_guard: Option, _bridge: super::direct_tool_bridge::DirectToolBridge, task: tokio::task::JoinHandle<()>, @@ -1800,7 +1803,7 @@ async fn start_external_mcp_loopback_with_mode( }; let state = ExternalMcpHttpState { bridge_url: bridge.url().to_string(), - root, + root: root.clone(), token: token.clone(), session_user_id: session.user_id, session_identity_generation: session.identity_generation, @@ -1814,25 +1817,40 @@ async fn start_external_mcp_loopback_with_mode( let _ = axum::serve(listener, app).await; }); let url = format!("http://127.0.0.1:{}{route}", address.port()); - let registry = EXTERNAL_MCP_SERVER.get_or_init(|| Mutex::new(None)); + let registry = + EXTERNAL_MCP_SERVER.get_or_init(|| Mutex::new(std::collections::BTreeMap::new())); let mut guard = registry .lock() .map_err(|_| "客户端 MCP 服务注册表不可用".to_string())?; - if let Some(previous) = guard.take() { - drop(previous); - } - *guard = Some(ExternalMcpServer { - _direct_turn_guard: direct_turn_guard, - _bridge: bridge, - task, - }); + guard.insert( + root, + ExternalMcpServer { + token: token.clone(), + _direct_turn_guard: direct_turn_guard, + _bridge: bridge, + task, + }, + ); Ok((url, token)) } pub(crate) fn stop_external_mcp_loopback() { if let Some(registry) = EXTERNAL_MCP_SERVER.get() { if let Ok(mut guard) = registry.lock() { - guard.take(); + guard.clear(); + } + } +} + +pub(crate) fn stop_external_mcp_loopback_for_root(root: &Path, token: &str) { + let Ok(root) = root.canonicalize() else { + return; + }; + if let Some(registry) = EXTERNAL_MCP_SERVER.get() { + if let Ok(mut guard) = registry.lock() { + if guard.get(&root).is_some_and(|server| server.token == token) { + guard.remove(&root); + } } } } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts index 05bbc90c6..93b3ba4c2 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts @@ -96,9 +96,7 @@ function directModelCallText(kind: ModelCallKind | null | undefined): string { case 'upstreamFailed': return ( (kind.native ? directNativeKindText(kind.native) : null) ?? - (kind.statusCode >= 500 - ? '智能服务暂时不可用,请稍后重试' - : '智能创作请求无效,请稍后重试') + directUpstreamStatusText(kind.statusCode) ); case 'paidCreditsInsufficient': return '泥点余额不足,本轮游戏生成已中断。请充值后发送“继续”,系统会从当前项目进度接着完成。'; @@ -113,6 +111,23 @@ function directModelCallText(kind: ModelCallKind | null | undefined): string { } } +function directUpstreamStatusText(statusCode: number): string { + switch (statusCode) { + case 401: + return '上游服务拒绝认证(HTTP 401),请重新登录后重试'; + case 403: + return '上游服务拒绝访问(HTTP 403),请检查账号权限后重试'; + case 408: + return '上游请求超时(HTTP 408),请稍后重试'; + case 429: + return '上游服务繁忙(HTTP 429),请稍后重试'; + default: + return statusCode >= 500 + ? `智能服务暂时不可用(HTTP ${statusCode}),请稍后重试` + : `智能创作请求被上游拒绝(HTTP ${statusCode}),请检查请求后重试`; + } +} + function directTurnDeadlineText(deadline: Deadline): string { switch (deadline) { case 'response-idle': diff --git a/apps/ai-game-creator-shell/tests/directTurnFailure.test.ts b/apps/ai-game-creator-shell/tests/directTurnFailure.test.ts new file mode 100644 index 000000000..2eb77fcc3 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/directTurnFailure.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; + +import { directTurnFailureNoticeText } from '../src/view/project-development/chat/conversation/directTurnFailure'; + +describe('DirectProject 上游失败文案', () => { + it.each([401, 403, 408, 429, 500, 502, 503, 504])( + '保留 HTTP %s 的具体原因', + (statusCode) => { + const text = directTurnFailureNoticeText({ + type: 'modelCallFailed', + kind: { type: 'upstreamFailed', statusCode, native: null }, + detail: `上游 HTTP ${statusCode}`, + }); + + expect(text).toContain(`HTTP ${statusCode}`); + expect(text).not.toContain('智能创作请求无效'); + }, + ); + + it('原生分类优先于 HTTP 状态文案', () => { + const text = directTurnFailureNoticeText({ + type: 'modelCallFailed', + kind: { + type: 'upstreamFailed', + statusCode: 429, + native: { type: 'context-window-exceeded' }, + }, + detail: 'upstream', + }); + + expect(text).toContain('模型上下文已超限'); + expect(text).not.toContain('HTTP 429'); + }); +}); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 67ff83da2..beca4a1a9 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -6290,7 +6290,8 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **根因 5(验收反馈复用 assistant ID)**:同一 client turn 进入 `ReviewRequired` 后会再次调用 cc。首次回复已经占用 `direct-codex::assistant`,第二次不同正文沿用该 ID 会被历史层正确拒绝为冲突,随后却被错误投影成 `runtime-unclassified`。真实诊断中可见「写入本项目对话历史失败:…assistant」且历史已经有该条回复。 - **根因 6(cc 字符串错误覆盖了上游分类)**:DirectProject 的 Claude Code 路由原本把 sidecar 返回的所有字符串都包装成 `LlmError::Transport`,因此 HTTP 429、401、408、5xx、sidecar 超时、空回执和无效 JSON 都显示成「执行通道未能建立或已断开」。 - **根因 7(内部 Claude MCP 桥误用外部只读模式)**:Claude Code sidecar 使用的 loopback MCP 原本调用 `start_external_client_tool_bridge(..., false)`,桥状态 `direct_turn_execution=false` 且没有 `begin_user_turn()` 授权;`agc_register_delivery_contract`、`agc_delivery_status`、`agc_update_plan` 每次都会返回 `ToolRequiresDirectTurn`,模型收到错误后又重复注册计划,最终陷入反馈死循环直到超时。 -- **现行口径**:cc 成功出口由放行侧补写 `DirectTurnTerminal::completed()`(`finish_if_unfinished` 幂等,codex 已写过终态时是空操作);cc 每次解析成功后都把实际落盘的回复 item id 同步下发 `ThreadEvent::item_completed(ThreadItem::Message{role:"assistant"})`。首个回复沿用 `direct-codex::assistant`,同一回合的反馈回复遇到内容冲突时追加 `:assistant:`,相同内容仍按原 ID 幂等;落盘失败按回合失败收口。Claude Code 的失败文本先投影到与 Codex 相同的 `LlmError` 分类:HTTP 状态、sidecar 超时、空回执和无效 JSON 分别复用上游、超时、空响应和反序列化语义;上游状态摘要与重试建议按状态码给出。内部 Claude Direct MCP 必须使用 `direct_turn_execution=true` 的工具桥并持有 `begin_user_turn()` guard;用户手动启动的外部 MCP 仍保持非 Direct 模式。sidecar 按 `mcp__` 前缀整体放行请求里声明的 MCP 服务器(权限策略在宿主侧执行)。 +- **根因 8(MCP 全局单槽位)**:外部 MCP 注册表原本只有一个 `Option`,不同项目的 Claude 回合会互相 abort;一个回合结束时的全局 stop 还可能误停另一个项目的桥。 +- **现行口径**:cc 成功出口由放行侧补写 `DirectTurnTerminal::completed()`(`finish_if_unfinished` 幂等,codex 已写过终态时是空操作);cc 每次解析成功后都把实际落盘的回复 item id 同步下发 `ThreadEvent::item_completed(ThreadItem::Message{role:"assistant"})`。首个回复沿用 `direct-codex::assistant`,同一回合的反馈回复遇到内容冲突时追加 `:assistant:`,相同内容仍按原 ID 幂等;落盘失败按回合失败收口。Claude Code 的失败文本先投影到与 Codex 相同的 `LlmError` 分类:HTTP 状态、sidecar 超时、空回执和无效 JSON 分别复用上游、超时、空响应和反序列化语义;上游状态摘要与重试建议按状态码给出。内部 Claude Direct MCP 必须使用 `direct_turn_execution=true` 的工具桥并持有 `begin_user_turn()` guard;用户手动启动的外部 MCP 仍保持非 Direct 模式。MCP 注册表按 canonical project root 分桶,停止操作再核对 server token,迟到的旧回合不能误停同项目的新桥。sidecar 按 `mcp__` 前缀整体放行请求里声明的 MCP 服务器(权限策略在宿主侧执行)。 - **诊断口径**:`agent.direct_turn.host_dropped` / `agent.direct_turn.panic` 里的令牌字段必须写 `tt=`,写 `turnToken=` 会命中脱敏标记,整行变成 ``,离线只剩"说不出原因"的 HostDropped。 - **验证**:dev 栈里用 CDP 注入真实回合(`node %TEMP%\agc-cdp.mjs `):①读文件轮 `claude-parse-done chars=108`,`.agent/conversations/project.jsonl` 出现 `direct-codex:cdp-…:assistant` 条目,回复内容与 `game/index.html` 前两行(`` / ``)逐字一致(证明宿主工具真的执行了);②聊天视图打开时注入 `只回三个字:收到了`,DOM 断言(`document.body.innerText`)同时出现用户气泡 `11:40:05`、助手回复 `收到了` 与 `本轮结束于 11:40:16 · 耗时 10.7秒`(证明 `item.completed` 实时投影生效,不必重进项目);同一日志不再出现新的 `host_dropped`。另有 `agent::claude_code_cli::tests::direct_claude_feedback_reply_does_not_fail_on_a_reused_client_turn_id` 回归覆盖同一回合两次不同回复。`cargo test … -- claude_code_cli::tests direct_turn_failure::tests` 19 passed。 - **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs`、`apps/ai-game-creator-shell/agent-sidecar/src/index.mjs`。