From 5093ec8172cc23b96ac3a31930bdebd4cd0eb205 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 2 Sep 2026 16:52:40 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E4=BC=98=E5=8C=96=20AGC=20=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E6=81=A2=E5=A4=8D=E8=B6=85=E6=97=B6=E4=B8=8E=20Runner?= =?UTF-8?q?=20=E5=90=AF=E5=8A=A8=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 客户端会话恢复增加阶段进度、超时和重试 共享 HTTP 传输增加可取消请求超时 Runner 启动探测遵守剩余启动期限 补充认证、HTTP 与 Runner 定向测试 同步客户端会话恢复技术方案 --- .../src-tauri/src/runner/client.rs | 27 ++- .../src-tauri/src/runner/tests.rs | 12 ++ .../src/app/AuthenticatedClient.tsx | 197 +++++++++++++++--- .../src/services/clientHttp.ts | 116 ++++++++++- .../tests/appSurface/auth.suite.ts | 36 ++++ .../tests/clientHttp.test.ts | 60 ++++++ ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 7 + 7 files changed, 421 insertions(+), 34 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs index f7e12fd44..6c6f9fdbf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs @@ -439,6 +439,26 @@ pub(super) fn ping_external_agent_runner( .map(|_| ()) } +pub(super) fn ping_external_agent_runner_with_timeout( + endpoint: &ExternalAgentRunnerEndpoint, + timeout: Duration, +) -> Result<(), String> { + if timeout.is_zero() { + return Err("Agent Runner ping 超时预算已耗尽".to_string()); + } + send_external_agent_runner_request_with_protocol_and_id_and_timeouts( + endpoint, + EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + random_identifier(b"genarrative-agent-runner-start-ping-id")?, + "runner.ping", + ExternalAgentRunnerRequestParams::default(), + EXTERNAL_AGENT_RUNNER_CONNECT_TIMEOUT.min(timeout), + timeout, + timeout, + ) + .map(|_| ()) +} + pub(super) fn retire_incompatible_external_agent_runner( endpoint_path: &Path, endpoint: &ExternalAgentRunnerEndpoint, @@ -1251,10 +1271,15 @@ pub(super) fn wait_for_external_agent_runner( let endpoint_path = external_agent_runner_endpoint_path(config_dir); let deadline = Instant::now() + EXTERNAL_AGENT_RUNNER_START_TIMEOUT; loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err("外部 Agent Runner 未在启动期限内就绪".to_string()); + } if let Some(endpoint) = read_current_external_agent_runner_endpoint(&endpoint_path, executable_fingerprint) { - if ping_external_agent_runner(&endpoint).is_ok() { + let ping_timeout = EXTERNAL_AGENT_RUNNER_IO_TIMEOUT.min(remaining); + if ping_external_agent_runner_with_timeout(&endpoint, ping_timeout).is_ok() { return Ok(endpoint); } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs index 2941e825d..ee56cb6e1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs @@ -282,6 +282,18 @@ fn runner_start_timeout_covers_cold_debug_binary_fingerprinting() { assert!(EXTERNAL_AGENT_RUNNER_START_TIMEOUT >= Duration::from_secs(30)); } +#[test] +fn startup_runner_ping_timeout_rejects_an_exhausted_budget() { + let endpoint = test_endpoint( + "startup-ping-timeout-token", + "startup-ping-timeout-boot", + 31_338, + ); + let error = ping_external_agent_runner_with_timeout(&endpoint, Duration::ZERO) + .expect_err("an exhausted startup budget must not attempt a runner ping"); + assert!(error.contains("超时预算已耗尽"), "{error}"); +} + #[test] fn forced_runner_drain_deadline_precedes_gui_hard_kill_deadline() { assert!( diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index 290a06cc1..980cae00b 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -4,6 +4,7 @@ import { type FormEvent, type ReactNode, useEffect, + useRef, useState, } from 'react'; @@ -49,6 +50,36 @@ type ClientRuntimeErrorBoundaryState = { errorMessage: string; }; +type AuthCheckStage = 'token' | 'refresh' | 'me' | 'runner'; + +const AUTH_CHECK_STAGE_LABELS: Record = { + token: '读取本地登录凭据', + refresh: '刷新登录状态', + me: '确认当前用户', + runner: '连接本地运行时', +}; + +const AUTH_CHECK_REQUEST_TIMEOUT_MS = 15_000; +// Existing endpoint probes can consume the 10s IPC budget before Runner's +// 30s startup deadline. Keep the UI fence slightly above that worst case. +const AUTH_CHECK_RUNNER_TIMEOUT_MS = 45_000; + +function withAuthCheckTimeout( + promise: Promise, + timeoutMs: number, + message: string, +) { + let timeoutId: number | undefined; + const timeout = new Promise((_, reject) => { + timeoutId = window.setTimeout(() => reject(new Error(message)), timeoutMs); + }); + return Promise.race([promise, timeout]).finally(() => { + if (timeoutId !== undefined) { + window.clearTimeout(timeoutId); + } + }); +} + export class ClientRuntimeErrorBoundary extends Component< ClientRuntimeErrorBoundaryProps, ClientRuntimeErrorBoundaryState @@ -101,6 +132,11 @@ export function AuthenticatedClient({ 'checking' | 'authenticated' | 'unauthenticated' >('checking'); const [authUser, setAuthUser] = useState(null); + const [authCheckStage, setAuthCheckStage] = useState('token'); + const [authCheckElapsedSeconds, setAuthCheckElapsedSeconds] = useState(0); + const [authCheckError, setAuthCheckError] = useState(''); + const [authCheckRetryKey, setAuthCheckRetryKey] = useState(0); + const authCheckRunRef = useRef(0); const [loginMode, setLoginMode] = useState<'code' | 'password'>('code'); const [phone, setPhone] = useState(''); const [code, setCode] = useState(''); @@ -147,100 +183,162 @@ export function AuthenticatedClient({ useEffect(() => { let disposed = false; async function hydrateAuth() { + const runId = ++authCheckRunRef.current; + const isActiveRun = () => !disposed && authCheckRunRef.current === runId; const hydrationGeneration = currentPlatformSessionGeneration(); const hydrationApiBaseUrl = getClientServerBaseUrl(); + setAuthCheckError(''); + setAuthCheckStage('token'); try { if (!getStoredAuthAccessToken()) { - const refreshed = await refreshPlatformSessionForGeneration( - hydrationGeneration, - hydrationApiBaseUrl, + setAuthCheckStage('refresh'); + const refreshed = await withAuthCheckTimeout( + refreshPlatformSessionForGeneration( + hydrationGeneration, + hydrationApiBaseUrl, + ), + AUTH_CHECK_REQUEST_TIMEOUT_MS, + '刷新登录状态超时,请检查服务器地址和网络后重试', ); if (!refreshed) { + if (isActiveRun()) { + setAuthStatus('unauthenticated'); + } return; } } - const user = await getCurrentClientAuthUser(hydrationApiBaseUrl); - if (disposed) { + if (!isActiveRun()) return; + setAuthCheckStage('me'); + const user = await withAuthCheckTimeout( + getCurrentClientAuthUser(hydrationApiBaseUrl), + AUTH_CHECK_REQUEST_TIMEOUT_MS, + '读取当前用户超时,请检查服务器地址和网络后重试', + ); + if (!isActiveRun()) { return; } if (user) { - const committedGeneration = await commitAuthenticatedPlatformSession( - user, - hydrationGeneration, - hydrationApiBaseUrl, + setAuthCheckStage('runner'); + const committedGeneration = await withAuthCheckTimeout( + commitAuthenticatedPlatformSession( + user, + hydrationGeneration, + hydrationApiBaseUrl, + ), + AUTH_CHECK_RUNNER_TIMEOUT_MS, + '连接本地运行时超时,请重试或重启客户端', ); if (committedGeneration === null) { return; } - if (disposed) { + if (!isActiveRun()) { return; } setAuthUser(user); + setAuthCheckError(''); setAuthStatus('authenticated'); return; } clearStoredAuthAccessToken(); setAuthStatus('unauthenticated'); } catch (error) { - if (disposed) { + if (!isActiveRun()) { return; } if ( getStoredAuthAccessToken() && isClientAuthRecoverableCheckError(error) ) { - setLoginStatus( - getClientAuthErrorMessage(error, '登录服务暂时不可用,请稍后重试'), + const message = getClientAuthErrorMessage( + error, + '登录服务暂时不可用,请稍后重试', ); + setAuthCheckError(message); + setLoginStatus(message); setAuthStatus('unauthenticated'); return; } if (getStoredAuthAccessToken()) { try { - const refreshed = await refreshPlatformSessionForGeneration( - hydrationGeneration, - hydrationApiBaseUrl, + if (!isActiveRun()) return; + setAuthCheckStage('refresh'); + const refreshed = await withAuthCheckTimeout( + refreshPlatformSessionForGeneration( + hydrationGeneration, + hydrationApiBaseUrl, + ), + AUTH_CHECK_REQUEST_TIMEOUT_MS, + '刷新登录状态超时,请检查服务器地址和网络后重试', ); if (!refreshed) { + if (isActiveRun()) { + setAuthStatus('unauthenticated'); + } return; } - const user = await getCurrentClientAuthUser(hydrationApiBaseUrl); - if (disposed) { + if (!isActiveRun()) return; + setAuthCheckStage('me'); + const user = await withAuthCheckTimeout( + getCurrentClientAuthUser(hydrationApiBaseUrl), + AUTH_CHECK_REQUEST_TIMEOUT_MS, + '读取当前用户超时,请检查服务器地址和网络后重试', + ); + if (!isActiveRun()) { return; } if (user) { - const committedGeneration = - await commitAuthenticatedPlatformSession( + if (!isActiveRun()) return; + setAuthCheckStage('runner'); + const committedGeneration = await withAuthCheckTimeout( + commitAuthenticatedPlatformSession( user, hydrationGeneration, hydrationApiBaseUrl, - ); + ), + AUTH_CHECK_RUNNER_TIMEOUT_MS, + '连接本地运行时超时,请重试或重启客户端', + ); if (committedGeneration === null) { return; } - if (disposed) { + if (!isActiveRun()) { return; } + setAuthCheckError(''); setAuthUser(user); setAuthStatus('authenticated'); return; } } catch (retryError) { + if (!isActiveRun()) { + return; + } if ( getStoredAuthAccessToken() && isClientAuthRecoverableCheckError(retryError) ) { - setLoginStatus( - getClientAuthErrorMessage( - retryError, - '登录服务暂时不可用,请稍后重试', - ), + const message = getClientAuthErrorMessage( + retryError, + '登录服务暂时不可用,请稍后重试', ); + setAuthCheckError(message); + setLoginStatus(message); setAuthStatus('unauthenticated'); return; } } } + if (!isActiveRun()) { + return; + } + if (isClientAuthRecoverableCheckError(error)) { + const message = getClientAuthErrorMessage( + error, + '登录服务暂时不可用,请稍后重试', + ); + setAuthCheckError(message); + setLoginStatus(message); + } clearStoredAuthAccessToken(); setAuthStatus('unauthenticated'); } @@ -249,7 +347,18 @@ export function AuthenticatedClient({ return () => { disposed = true; }; - }, []); + }, [authCheckRetryKey]); + + useEffect(() => { + if (authStatus !== 'checking') { + return; + } + setAuthCheckElapsedSeconds(0); + const timer = window.setInterval(() => { + setAuthCheckElapsedSeconds((current) => current + 1); + }, 1000); + return () => window.clearInterval(timer); + }, [authStatus, authCheckRetryKey]); useEffect( () => @@ -358,6 +467,7 @@ export function AuthenticatedClient({ return; } setAuthUser(user); + setAuthCheckError(''); setAuthStatus('authenticated'); setCode(''); setPassword(''); @@ -383,6 +493,7 @@ export function AuthenticatedClient({ nativeClearError = error; } setAuthUser(null); + setAuthCheckError(''); setAuthStatus('unauthenticated'); setLoginStatus( nativeClearError @@ -392,6 +503,7 @@ export function AuthenticatedClient({ } if (authStatus === 'checking') { + const stageLabel = AUTH_CHECK_STAGE_LABELS[authCheckStage]; return (
陶泥儿

正在检查登录状态

+

+ {stageLabel} · 已等待 {authCheckElapsedSeconds} 秒 +

+

+ {authCheckElapsedSeconds >= 8 + ? '检查时间较长,请确认服务器可访问;若持续无响应可稍后重试' + : '正在恢复会话,请稍候'} +

); @@ -417,6 +537,23 @@ export function AuthenticatedClient({

登录陶泥儿 GameAgent

登录后进入首页和本地项目工作区

+ {authCheckError ? ( +
+

{authCheckError}

+ +
+ ) : null}