优化 AGC 会话恢复超时与 Runner 启动校验 #253

Merged
kdletters merged 3 commits from codex/auth-session-timeout into master 2026-09-02 18:15:39 +08:00
8 changed files with 427 additions and 36 deletions
@@ -2403,13 +2403,17 @@ pub(super) fn try_acquire_game_creator_agent_runtime_task_lock_with_wait(
root: &Path,
agent_id: &str,
) -> Result<Option<AgentRuntimeTaskLock>, String> {
for attempt in 0..25 {
// Runtime state transitions can persist several audit projections while holding
// the lane lock. Keep the wait bounded, but allow a slow CI/disk-backed
// transition to finish before reporting a false busy error.
const MAX_ATTEMPTS: usize = 100;
for attempt in 0..MAX_ATTEMPTS {
if let Some(runtime_lock) =
try_acquire_game_creator_agent_runtime_task_lock(root, agent_id)?
{
return Ok(Some(runtime_lock));
}
if attempt < 24 {
if attempt + 1 < MAX_ATTEMPTS {
std::thread::sleep(Duration::from_millis(10));
}
}
@@ -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);
}
}
@@ -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!(
@@ -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<AuthCheckStage, string> = {
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<T>(
promise: Promise<T>,
timeoutMs: number,
message: string,
) {
let timeoutId: number | undefined;
const timeout = new Promise<T>((_, 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<AuthUser | null>(null);
const [authCheckStage, setAuthCheckStage] = useState<AuthCheckStage>('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 (
<main
className="client-auth-shell platform-theme platform-theme--light"
@@ -400,6 +512,14 @@ export function AuthenticatedClient({
<section className="client-auth-panel">
<img className="client-auth-logo" src={brandIcon} alt="陶泥儿" />
<h1></h1>
<p className="client-auth-status" aria-live="polite">
{stageLabel} · {authCheckElapsedSeconds}
</p>
<p className="client-auth-status" aria-live="polite">
{authCheckElapsedSeconds >= 8
? '检查时间较长,请确认服务器可访问;若持续无响应可稍后重试'
: '正在恢复会话,请稍候'}
</p>
</section>
</main>
);
@@ -417,6 +537,23 @@ export function AuthenticatedClient({
<h1> GameAgent</h1>
<p></p>
</div>
{authCheckError ? (
<div role="alert" className="client-auth-status">
<p>{authCheckError}</p>
<button
type="button"
onClick={() => {
beginPlatformSessionTransition();
setAuthCheckError('');
setAuthStatus('checking');
setAuthCheckRetryKey((current) => current + 1);
}}
disabled={loginBusy || codeBusy}
>
</button>
</div>
) : null}
<label>
<select
@@ -523,7 +660,9 @@ export function AuthenticatedClient({
<button type="submit" disabled={loginBusy}>
{loginBusy ? '登录中' : '登录'}
</button>
<p className="client-auth-status">{loginStatus}</p>
{!authCheckError ? (
<p className="client-auth-status">{loginStatus}</p>
) : null}
</form>
</main>
);
@@ -4,6 +4,32 @@ export const AGC_DEVELOPMENT_API_BASE_URL = 'https://dev.genarrative.world';
export const AGC_RELEASE_API_BASE_URL = 'https://www.genarrative.world';
export const AGC_CLIENT_MARKER_HEADER = 'X-Genarrative-Client';
export const AGC_CLIENT_MARKER_VALUE = 'agc';
/**
* Upper bound for the initial network transaction (DNS/connect/response
* headers). Callers may override this for a request that legitimately needs
* more time; the default prevents auth/bootstrap requests from hanging
* forever when the selected server or proxy is unavailable.
*/
export const CLIENT_HTTP_DEFAULT_TIMEOUT_MS = 15_000;
export class ClientHttpTimeoutError extends Error {
readonly code = 'CLIENT_HTTP_TIMEOUT';
readonly timeoutMs: number;
readonly url: string;
constructor(url: string, timeoutMs: number) {
super(`请求超时(${timeoutMs} ms):${url}`);
this.name = 'ClientHttpTimeoutError';
this.timeoutMs = timeoutMs;
this.url = url;
}
}
export function isClientHttpTimeoutError(
error: unknown,
): error is ClientHttpTimeoutError {
return error instanceof ClientHttpTimeoutError;
}
export type ClientServerPreset = 'release' | 'dev' | 'custom';
@@ -171,7 +197,11 @@ export function resolveClientHttpTarget(
export async function fetchClientHttp(
url: string,
init: RequestInit,
options: { serverBaseUrl?: string } = {},
options: {
serverBaseUrl?: string;
/** Set to null to opt out for a long-running request. */
timeoutMs?: number | null;
} = {},
): Promise<Response> {
const currentContext = currentClientHttpContext();
const serverBaseUrl = options.serverBaseUrl
@@ -186,8 +216,86 @@ export async function fetchClientHttp(
: { ...currentContext, serverBaseUrl },
);
const markedInit = withAgcClientMarker(init);
if (target.transport === 'tauri-http') {
return tauriHttpFetch(target.url, markedInit);
// Always use a private controller so an internal timeout cannot mutate a
// caller-owned AbortSignal. The caller's signal is still propagated in
// both directions, preserving normal AbortError behaviour for user aborts.
const timeoutMs =
options.timeoutMs === undefined
? CLIENT_HTTP_DEFAULT_TIMEOUT_MS
: options.timeoutMs;
if (timeoutMs === null) {
if (target.transport === 'tauri-http') {
return tauriHttpFetch(target.url, markedInit);
}
return fetch(target.url, markedInit);
}
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
throw new RangeError('请求超时时间必须是大于 0 的有限数值');
}
const controller = new AbortController();
let timedOut = false;
const callerSignal = markedInit.signal;
const forwardCallerAbort = () => {
// AbortSignal.reason is available in modern browsers/Tauri WebViews. The
// fallback keeps compatibility with older runtimes and test doubles.
const reason = callerSignal?.reason;
try {
controller.abort(reason);
} catch {
controller.abort();
}
};
if (callerSignal) {
if (callerSignal.aborted) {
forwardCallerAbort();
} else {
callerSignal.addEventListener('abort', forwardCallerAbort, {
once: true,
});
}
}
const requestInit = { ...markedInit, signal: controller.signal };
let request: Promise<Response>;
try {
// Keep invocation synchronous so an already-aborted caller signal is
// observed by transports that only subscribe to `abort` events.
const responsePromise =
target.transport === 'tauri-http'
? tauriHttpFetch(target.url, requestInit)
: fetch(target.url, requestInit);
request = Promise.resolve(responsePromise);
} catch (error) {
callerSignal?.removeEventListener('abort', forwardCallerAbort);
throw error;
}
// A timed-out request is intentionally not awaited after the race settles,
// but transports may still reject when the abort reaches them. Attach a
// sink to avoid an unhandled rejection while keeping the original promise
// in the race for normal errors.
void request.catch(() => undefined);
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(() => {
timedOut = true;
controller.abort();
reject(new ClientHttpTimeoutError(target.url, timeoutMs));
}, timeoutMs);
});
try {
return await Promise.race([request, timeout]);
} catch (error) {
// Some transports reject with a generic error after AbortController.abort;
// expose a stable, actionable error to auth/bootstrap callers.
if (timedOut) {
throw new ClientHttpTimeoutError(target.url, timeoutMs);
}
throw error;
} finally {
if (timeoutHandle !== undefined) clearTimeout(timeoutHandle);
callerSignal?.removeEventListener('abort', forwardCallerAbort);
}
return fetch(target.url, markedInit);
}
@@ -16,6 +16,7 @@ import {
resetPlatformSessionStateForTests,
} from '../../src/services/platformSession';
import {
act,
AuthenticatedClient,
expect,
fireEvent,
@@ -35,6 +36,41 @@ export function registerAuthTests() {
delete window.__TAURI__;
});
it('leaves startup loading with an actionable retry after auth service timeout', async () => {
vi.useFakeTimers();
let releaseRefresh: (() => void) | null = null;
vi.spyOn(globalThis, 'fetch').mockImplementation(
async (input: RequestInfo | URL) => {
if (String(input) === '/api/auth/refresh') {
return new Promise<Response>((resolve) => {
releaseRefresh = () => resolve(new Response('', { status: 401 }));
});
}
throw new Error(`unexpected fetch ${String(input)}`);
},
);
render(
React.createElement(AuthenticatedClient, null, () =>
React.createElement('main', { 'aria-label': '已登录' }),
),
);
expect(screen.getByRole('main', { name: '登录状态检查' })).not.toBeNull();
await act(async () => {
await vi.advanceTimersByTimeAsync(15_000);
});
expect(screen.getByRole('main', { name: '登录' })).not.toBeNull();
expect(screen.getByRole('alert')).not.toBeNull();
expect(
screen.getByRole('button', { name: '重试登录状态检查' }),
).not.toBeNull();
releaseRefresh?.();
await Promise.resolve();
vi.useRealTimers();
});
it('renders the unauthenticated client with the shared light platform theme and product image', async () => {
vi.spyOn(globalThis, 'fetch').mockImplementation(
async (input: RequestInfo | URL) => {
@@ -10,6 +10,7 @@ import {
AGC_CLIENT_MARKER_VALUE,
AGC_DEVELOPMENT_API_BASE_URL,
AGC_RELEASE_API_BASE_URL,
ClientHttpTimeoutError,
fetchClientHttp,
getClientServerBaseUrl,
getClientServerSelection,
@@ -25,6 +26,7 @@ vi.mock('@tauri-apps/plugin-http', () => ({
describe('AGC client HTTP transport', () => {
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
vi.unstubAllEnvs();
vi.unstubAllGlobals();
@@ -250,4 +252,62 @@ describe('AGC client HTTP transport', () => {
url: `${AGC_DEVELOPMENT_API_BASE_URL}/api/auth/me`,
});
});
it('aborts a stalled Web request at the configured timeout', async () => {
vi.useFakeTimers();
const fetchMock = vi.fn(() => new Promise<Response>(() => {}));
vi.stubGlobal('fetch', fetchMock);
const request = fetchClientHttp('/api/auth/me', {}, { timeoutMs: 25 });
const timeoutAssertion = expect(request).rejects.toMatchObject({
name: 'ClientHttpTimeoutError',
code: 'CLIENT_HTTP_TIMEOUT',
timeoutMs: 25,
url: '/api/auth/me',
});
await vi.advanceTimersByTimeAsync(25);
await timeoutAssertion;
expect(fetchMock).toHaveBeenCalledTimes(1);
const [, forwardedInit] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(forwardedInit.signal).toBeInstanceOf(AbortSignal);
expect((forwardedInit.signal as AbortSignal).aborted).toBe(true);
});
it('preserves caller AbortError and does not report it as a timeout', async () => {
const fetchMock = vi.fn(
(_url: string, init: RequestInit) =>
new Promise<Response>((_, reject) => {
init.signal?.addEventListener('abort', () => {
reject(
new DOMException('The operation was aborted.', 'AbortError'),
);
});
}),
);
vi.stubGlobal('fetch', fetchMock);
const callerController = new AbortController();
const request = fetchClientHttp(
'/api/auth/me',
{ signal: callerController.signal },
{ timeoutMs: 10_000 },
);
callerController.abort();
await expect(request).rejects.toMatchObject({ name: 'AbortError' });
await expect(request).rejects.not.toBeInstanceOf(ClientHttpTimeoutError);
});
it('allows explicitly disabling the timeout for long-running requests', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal('fetch', fetchMock);
await expect(
fetchClientHttp('/api/agent/run', {}, { timeoutMs: null }),
).resolves.toBeInstanceOf(Response);
const [, forwardedInit] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(forwardedInit.signal).toBeUndefined();
});
});
@@ -1,5 +1,12 @@
# AI 游戏创作智能体 App 实施计划
## 2026-09-02 客户端会话恢复可观测性与超时兜底
- AGC 客户端启动恢复按“读取本地凭据 → 刷新会话(无 token 或失效时)→ 读取当前用户 → Tauri 本地运行时会话安装”阶段执行。界面必须展示当前阶段和已等待时间;不能以无期限的单一 loading 文案隐藏网络或 Runner 故障。
- 客户端 HTTP 传输默认使用 15 秒超时并通过独立 `AbortController` 终止请求;调用方可为确需长耗时的请求显式传入 `timeoutMs: null`。调用方主动取消仍保留原始 `AbortError`,超时使用稳定的 `ClientHttpTimeoutError`,由认证层转换为可操作的中文提示。
- 会话恢复或本地 Runner 连接超时后必须进入登录页并提供“重试登录状态检查”。重试递增恢复代次并以运行标识忽略旧恢复任务的迟到 UI 写回;不得清除仍可用于后续重试的 access token,也不得重复并发刷新同一服务器的 refresh 请求。
- Tauri Runner 的启动与 IPC 超时继续以 `runner/protocol.rs` 的 30 秒启动、10 秒读写为权威;启动等待循环会把 endpoint 探测预算裁剪到剩余启动期限,避免单次 ping 把 30 秒门禁延长。考虑复用旧 endpoint 前可能先消耗一次 IPC 等待,前端 UI 兜底取 45 秒,不改变 Runner 协议、启动策略或认证接口。
## 2026-08-26 运行中自主扩图提案
- `agent-runtime-orchestration` 提供严格 serde 的 `GraphProposal``TaskProposal``GraphEdge``GraphLimits`。宿主可把 LLM function-call arguments 解析后交给 `TaskGraph::apply_proposal`,在内存中得到新的、完整校验过的候选 DAG。