Merge remote-tracking branch 'origin/master' into fix/remove-read-skill-tool
This commit is contained in:
@@ -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。
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
export function formatMudPointCount(value: number, compact = false) {
|
||||
const normalizedValue = Math.max(0, Math.round(value));
|
||||
if (compact && normalizedValue >= 100_000_000) {
|
||||
return `${(normalizedValue / 100_000_000).toFixed(1)}亿`;
|
||||
return `${(Math.floor(normalizedValue / 10_000_000) / 10).toFixed(1)}亿`;
|
||||
}
|
||||
if (compact && normalizedValue >= 10_000) {
|
||||
return `${(normalizedValue / 10_000).toFixed(1)}万`;
|
||||
return `${(Math.floor(normalizedValue / 1_000) / 10).toFixed(1)}万`;
|
||||
}
|
||||
return normalizedValue.toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user