补齐 Claude 上游错误展示与 MCP 并发隔离
Project CI / AI game creator shell Rust crates (push) Successful in 3m15s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 4m30s
Project CI / Backend tests (push) Failing after 4m50s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 6m23s
Project CI / Frontend tests (push) Successful in 3m38s
Project CI / Native shell tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled

前端保留 401/403/408/429/5xx 状态并补回归测试

按项目隔离 Claude MCP 注册表并防止旧回合误停新桥

更新 DirectProject 排障记录
This commit is contained in:
kdletters
2026-10-03 19:21:18 +08:00
parent e5fa123483
commit d4be3dd97a
5 changed files with 93 additions and 19 deletions
@@ -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}"))?;
@@ -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<Mutex<()>> = OnceLock::new();
static EXTERNAL_MCP_SERVER: OnceLock<Mutex<Option<ExternalMcpServer>>> = OnceLock::new();
static EXTERNAL_MCP_SERVER: OnceLock<
Mutex<std::collections::BTreeMap<PathBuf, ExternalMcpServer>>,
> = OnceLock::new();
tokio::task_local! {
static EXTERNAL_MCP_BRIDGE_URL: String;
}
pub(crate) struct ExternalMcpServer {
token: String,
_direct_turn_guard: Option<super::direct_tool_bridge::DirectToolBridgeTurnGuard>,
_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);
}
}
}
}
@@ -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':
@@ -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');
});
});