Merge branch 'master' into opt/win-ssache
Project CI / Repository checks (pull_request) Successful in 2m29s
Project CI / Frontend tests (pull_request) Successful in 3m18s
Project CI / Backend tests (pull_request) Successful in 8m55s
Project CI / Native shell tests (pull_request) Failing after 16m10s

This commit is contained in:
2026-09-14 15:19:12 +08:00
10 changed files with 616 additions and 64 deletions
@@ -145,6 +145,11 @@ export function AuthenticatedClient({
const [authCheckError, setAuthCheckError] = useState('');
const [authCheckRetryKey, setAuthCheckRetryKey] = useState(0);
const authCheckRunRef = useRef(0);
/**
* 登录尝试代次。UI 的 45s 围栏只约束"等待":底层 native 提交仍在队列里跑,所以围栏超时后
* 仍要有人接手这次提交的结果。代次确保只有最近一次登录尝试的迟到结果能改变界面。
*/
const loginAttemptRef = useRef(0);
const [loginMode, setLoginMode] = useState<'code' | 'password'>('code');
const [phone, setPhone] = useState('');
const [code, setCode] = useState('');
@@ -477,6 +482,7 @@ export function AuthenticatedClient({
return;
}
const loginApiBaseUrl = getClientServerBaseUrl(persistedSelection);
const loginAttempt = (loginAttemptRef.current += 1);
setLoginBusy(true);
setLoginStatus('正在登录');
const loginGeneration = beginPlatformSessionTransition();
@@ -493,15 +499,43 @@ export function AuthenticatedClient({
password,
loginApiBaseUrl,
);
const committedGeneration = await withAuthCheckTimeout(
commitAuthenticatedPlatformSession(
user,
loginGeneration,
loginApiBaseUrl,
),
AUTH_CHECK_RUNNER_TIMEOUT_MS,
'连接本地运行时超时,请重试或重启客户端',
const commitRequest = commitAuthenticatedPlatformSession(
user,
loginGeneration,
loginApiBaseUrl,
);
let commitFenceExpired = false;
// 围栏只放弃等待,不放弃结果:本地运行时确实装好会话时,界面必须跟着进工作区,
// 否则用户停在登录页、而后端已经认为登录成功(重试也会被已装的会话挡住)。
void commitRequest
.then((committedGeneration) => {
if (
committedGeneration === null ||
!commitFenceExpired ||
loginAttemptRef.current !== loginAttempt ||
currentPlatformSessionGeneration() !== committedGeneration
) {
return;
}
setAuthUser(user);
setAuthCheckError('');
setAuthStatus('authenticated');
setCode('');
setPassword('');
setLoginStatus('本地运行时登录态已确认');
})
.catch(() => undefined);
let committedGeneration: number | null;
try {
committedGeneration = await withAuthCheckTimeout(
commitRequest,
AUTH_CHECK_RUNNER_TIMEOUT_MS,
'连接本地运行时超时,请重试或重启客户端',
);
} catch (error) {
commitFenceExpired = true;
throw error;
}
if (committedGeneration === null) {
return;
}
@@ -94,6 +94,7 @@ export function WorkspaceLauncherShell({
createHomeDraftAutomatically,
openProject,
homeCreationBusy,
homeCreationRecoverableProjectPath,
} = homeProject;
const switchedToGameRuntime =
gameRuntimeSwitch !== null &&
@@ -524,6 +525,7 @@ export function WorkspaceLauncherShell({
recentProjectRows={recentProjectRows}
onCreateDraftAutomatically={createHomeDraftAutomatically}
creationBusy={homeCreationBusy}
recoverableCreatedProjectPath={homeCreationRecoverableProjectPath}
onProjectsOpen={() => setLauncherView('projects')}
onProjectOpen={(path) => {
setProjectPath(path);
@@ -92,6 +92,37 @@ async function suggestAutomaticProjectName(
}
}
/**
* 自动建项的兜底期限。
*
* 建项要跑脚手架和依赖安装,慢是正常的,所以这不是"失败期限"而是"解围期限":到点后不再让首页
* 入口无限占用工作区闸门(底层创建不会被取消,迟到成功仍会照常进入项目)。没有这个期限时,
* 一次卡死的 `create_automatic_local_game_project` 会让首页之后的打开/新建全部无法进行。
*/
const HOME_CREATION_DEADLINE_MS = 10 * 60_000;
const HOME_CREATION_WATCHDOG_MESSAGE =
'工作区创建超过 10 分钟仍未返回;可先打开其它项目,或在项目列表查看已创建的工作区';
/**
* 建项已经落盘、但没能进入项目时,给首页一个恢复入口(打开已创建的工作区)。
* 只有"已经知道项目路径且当前没有在跑"的阶段才提示,避免和进行中的建项打架。
*/
function resolveRecoverableHomeProjectPath(
operation: ClientOperation<
'home-create',
{ draft: HomeDraft; startMode: ProjectStartMode }
> | null,
) {
if (!operation) return '';
if (
operation.phase !== 'retryable-failure' &&
operation.phase !== 'unknown'
) {
return '';
}
return operation.scope.projectPath ?? '';
}
export function useHomeProjectCreation({
setStatus,
setLauncherView,
@@ -146,6 +177,14 @@ export function useHomeProjectCreation({
* 一次代次,await 回来时已经不是最新代次的结果整体丢弃。
*/
const projectEntryTokenRef = useRef(0);
/**
* 首页自动建项的尝试代次与"底层创建仍未返回"标记。
*
* 看门狗到点后会放开工位闸门(让用户还能打开其它项目),但底层创建仍在跑。此时既不允许
* 并发再建一个项目(会产生重复工作区),也不能让迟到的创建结果强行劫持用户已经打开的别的项目。
*/
const homeCreationAttemptRef = useRef(0);
const homeCreationUnresolvedRef = useRef(false);
function validateProjectPath(nextProjectPath: string) {
const trimmedProjectPath = nextProjectPath.trim();
@@ -667,14 +706,21 @@ export function useHomeProjectCreation({
if (projectActionRef.current || homeCreationIsBusy()) {
return '已有项目操作进行中,请稍候';
}
if (homeCreationUnresolvedRef.current) {
// 上一次建项的底层调用还没返回(可能已经超过看门狗期限)。此时放行会真的建出第二个
// 工作区,所以只挡"再建一个",不挡打开/查看已有项目。
return '上一次工作区创建仍未返回,请稍候,或重启客户端后再试';
}
const invoke = resolveTauriInvoke();
if (!invoke) {
throw new Error('需要在陶泥儿客户端内运行');
}
const attempt = (homeCreationAttemptRef.current += 1);
const isCurrentAttempt = () => homeCreationAttemptRef.current === attempt;
const operation = createClientOperation(
'home-create',
{ draft, startMode },
{ deadlineMs: null, cancellable: false },
{ deadlineMs: HOME_CREATION_DEADLINE_MS, cancellable: false },
);
setHomeCreationOperation(transitionClientOperation(operation, 'network'));
// This action is owned by WorkspaceLauncher rather than HomeView. The
@@ -682,62 +728,123 @@ export function useHomeProjectCreation({
// the guard while project creation or first-turn import is still running.
projectActionRef.current = 'creating';
setProjectAction('creating');
homeCreationUnresolvedRef.current = true;
setStatus('正在创建工作区');
try {
const suggestedName = options.suggestName
? await suggestAutomaticProjectName(invoke, draft)
: null;
const result = await invoke<InitLocalProjectResult>(
'create_automatic_local_game_project',
{
name: suggestedName,
planning: startMode === 'planning',
},
);
setHomeCreationOperation(
transitionClientOperation(operation, 'project', {
scope: { projectPath: result.projectPath },
}),
);
try {
await enterCreatedHomeProject(
invoke,
result,
draft.creationType,
draft.prompt,
draft.attachments,
startMode,
);
let createdProjectPath: string | undefined;
let entryTokenAtWatchdog: number | null = null;
const operationScope = () =>
createdProjectPath ? { scope: { projectPath: createdProjectPath } } : {};
let watchdogId: number | undefined;
/**
* 看门狗到点后**只解围不取消**:首页入口立刻拿回控制权(否则 `await` 不结束,
* 首页按钮会一直禁用),而底层建项继续在后台跑;它真的成功时会照常进入项目。
* 这就是为什么这里用 `return string` 而不是抛错——首页的 catch 会把错误统一压成
* 「创建未完成,请重试」,反而丢掉"只是慢"这个信息。
*/
const watchdog = new Promise<string>((resolve) => {
watchdogId = window.setTimeout(() => {
if (!isCurrentAttempt() || !homeCreationUnresolvedRef.current) return;
entryTokenAtWatchdog = projectEntryTokenRef.current;
setHomeCreationOperation(
transitionClientOperation(operation, 'success', {
transitionClientOperation(operation, 'unknown', operationScope()),
);
setStatus(HOME_CREATION_WATCHDOG_MESSAGE);
if (projectActionRef.current === 'creating') {
projectActionRef.current = null;
setProjectAction(null);
}
resolve(HOME_CREATION_WATCHDOG_MESSAGE);
}, HOME_CREATION_DEADLINE_MS);
});
/** 后台继续跑的建项主体:用户可见的等待由 `watchdog` 兜底,这里只负责最终落定。 */
const creation = (async () => {
try {
const suggestedName = options.suggestName
? await suggestAutomaticProjectName(invoke, draft)
: null;
const result = await invoke<InitLocalProjectResult>(
'create_automatic_local_game_project',
{
name: suggestedName,
planning: startMode === 'planning',
},
);
createdProjectPath = result.projectPath;
setHomeCreationOperation(
transitionClientOperation(operation, 'project', {
scope: { projectPath: result.projectPath },
}),
);
setStatus('已创建工作区,正在开始智能创作');
return '已创建工作区并进入项目开发';
if (
entryTokenAtWatchdog !== null &&
projectEntryTokenRef.current !== entryTokenAtWatchdog
) {
// 看门狗之后用户已经进了别的项目:工作区确实建好了,但不能在此时把工作区切过去。
rememberRecentWorkspace(result.projectPath);
setHomeCreationOperation(
transitionClientOperation(operation, 'retryable-failure', {
scope: { projectPath: result.projectPath },
}),
);
setStatus('工作区已创建;可在项目列表打开');
return '工作区已创建,可从项目列表打开';
}
try {
await enterCreatedHomeProject(
invoke,
result,
draft.creationType,
draft.prompt,
draft.attachments,
startMode,
);
setHomeCreationOperation(
transitionClientOperation(operation, 'success', {
scope: { projectPath: result.projectPath },
}),
);
setStatus('已创建工作区,正在开始智能创作');
return '已创建工作区并进入项目开发';
} catch (error) {
// 项目目录已经建好了:把它登记进最近项目,用户可以直接打开,不必重新建一遍。
rememberRecentWorkspace(result.projectPath);
setHomeCreationOperation(
transitionClientOperation(operation, 'retryable-failure', {
scope: { projectPath: result.projectPath },
}),
);
const message = `工作区已创建;首条需求投递失败:${
error instanceof Error ? error.message : String(error)
}`;
setStatus(message);
throw new Error(message);
}
} catch (error) {
setHomeCreationOperation(
transitionClientOperation(operation, 'retryable-failure', {
scope: { projectPath: result.projectPath },
}),
transitionClientOperation(
operation,
'retryable-failure',
operationScope(),
),
);
const message = `工作区已创建;首条需求投递失败:${
error instanceof Error ? error.message : String(error)
}`;
setStatus(message);
throw new Error(message);
if (createdProjectPath) {
rememberRecentWorkspace(createdProjectPath);
}
throw error;
} finally {
if (watchdogId !== undefined) {
window.clearTimeout(watchdogId);
}
homeCreationUnresolvedRef.current = false;
if (isCurrentAttempt() && projectActionRef.current === 'creating') {
projectActionRef.current = null;
setProjectAction(null);
}
}
} catch (error) {
setHomeCreationOperation(
transitionClientOperation(operation, 'retryable-failure'),
);
throw error;
} finally {
if (projectActionRef.current === 'creating') {
projectActionRef.current = null;
setProjectAction(null);
}
}
})();
// 后台主体不会因为竞速落定而停止:这里只防止它变成未处理的拒绝。
void creation.catch(() => undefined);
return await Promise.race([creation, watchdog]);
}
async function pickAndOpenProject() {
@@ -850,6 +957,9 @@ export function useHomeProjectCreation({
projectBusy: projectAction !== null,
homeCreationOperation,
homeCreationBusy: homeCreationIsBusy(),
homeCreationRecoverableProjectPath: resolveRecoverableHomeProjectPath(
homeCreationOperation,
),
pendingNonEmptyProject,
resetLauncherHomeDraft,
startGameFromApprovedGdd,
@@ -43,7 +43,16 @@ let platformSessionOperation: ClientOperation<
'auth-transition',
{ userId: string | null }
> | null = null;
let platformSessionNativeMutationTail: Promise<void> = Promise.resolve();
/**
* 本地会话写入的排队闸门。
*
* Rust 侧 `install_platform_session_in` / `clear_platform_session_in` 按 generation 单调校验:
* 更旧的 install 与更旧的 clear 都会被拒绝。因此渲染层必须保证的只有"新 generation 不被旧调用
* 无限挡住",而不需要让队列永远等下去。这里给闸门加一个上限:底层 invoke 迟迟不返回(例如
* Runner 卡住、IPC 不回调)时,后续登录/退出仍能继续推进,迟到的旧写入由 Rust 按代次拒绝。
*/
const PLATFORM_SESSION_NATIVE_MUTATION_ABANDONMENT_MS = 60_000;
let platformSessionNativeMutationGate: Promise<void> = Promise.resolve();
const platformSessionRefreshListeners =
new Set<PlatformSessionRefreshListener>();
const platformSessionGenerationListeners =
@@ -104,15 +113,34 @@ async function clearNativePlatformSession(generation: number) {
await invoke('clear_platform_account_session', { generation });
}
function waitForNativeMutationAbandonment(
settled: Promise<void>,
timeoutMs: number,
) {
let timerId: number | undefined;
const abandoned = new Promise<void>((resolve) => {
timerId = window.setTimeout(resolve, timeoutMs);
});
return Promise.race([settled, abandoned]).finally(() => {
if (timerId !== undefined) {
window.clearTimeout(timerId);
}
});
}
function enqueuePlatformSessionNativeMutation<T>(
operation: () => Promise<T>,
abandonmentMs = PLATFORM_SESSION_NATIVE_MUTATION_ABANDONMENT_MS,
): Promise<T> {
const pending = platformSessionNativeMutationTail
const pending = platformSessionNativeMutationGate
.catch(() => undefined)
.then(operation);
platformSessionNativeMutationTail = pending.then(
() => undefined,
() => undefined,
platformSessionNativeMutationGate = waitForNativeMutationAbandonment(
pending.then(
() => undefined,
() => undefined,
),
abandonmentMs,
);
return pending;
}
@@ -135,7 +163,15 @@ async function readNativePlatformSessionGenerationFloor() {
async function reserveNativePlatformSessionGeneration() {
platformNativeGenerationFloorPromise ??=
readNativePlatformSessionGenerationFloor();
const nativeGenerationFloor = await platformNativeGenerationFloorPromise;
let nativeGenerationFloor: number;
try {
nativeGenerationFloor = await platformNativeGenerationFloorPromise;
} catch (error) {
// 一次瞬时失败(IPC 抖动、Runner 刚重启)不能被缓存成"永久失败":否则本次渲染进程
// 内的后续登录/退出都会在同一个已 reject 的 promise 上失败,用户重试也不会重新读取。
platformNativeGenerationFloorPromise = null;
throw error;
}
platformNativeGeneration = Math.max(
platformNativeGeneration + 1,
platformAuthGeneration,
@@ -486,7 +522,7 @@ export function resetPlatformSessionStateForTests() {
desiredPlatformSession = null;
platformSessionRefreshPromise = null;
platformSessionOperation = null;
platformSessionNativeMutationTail = Promise.resolve();
platformSessionNativeMutationGate = Promise.resolve();
platformSessionRefreshListeners.clear();
platformSessionGenerationListeners.clear();
}
@@ -113,6 +113,10 @@ type HomeViewProps = {
startMode: ProjectStartMode,
) => Promise<string>;
creationBusy?: boolean;
/**
* 建项已经落盘、但当前没能进入项目时给出该项目路径,让用户直接打开而不是重建一遍。
*/
recoverableCreatedProjectPath?: string;
onProjectsOpen: () => void;
onProjectOpen: (path: string) => void;
onProjectPick: () => void;
@@ -125,6 +129,7 @@ export default function HomeView({
recentProjectRows,
onCreateDraftAutomatically,
creationBusy = false,
recoverableCreatedProjectPath = '',
onProjectsOpen,
onProjectOpen,
onProjectPick,
@@ -295,6 +300,21 @@ export default function HomeView({
{status}
</p>
) : null}
{recoverableCreatedProjectPath && !creationBusy ? (
<p
className="m-0 flex w-[min(814px,calc(100vw-122px))] flex-wrap items-center gap-2 text-sm text-(--platform-warm-text) max-[760px]:w-[min(100%,calc(100vw-76px))]"
role="status"
>
<span>{recoverableCreatedProjectPath}</span>
<button
className="cursor-pointer rounded-md border border-(--platform-surface-border) bg-(--platform-input-fill) px-2 py-1 text-[12px] text-(--platform-text-strong)"
type="button"
onClick={() => onProjectOpen(recoverableCreatedProjectPath)}
>
</button>
</p>
) : null}
</section>
<section
@@ -270,6 +270,185 @@ export function registerAuthTests() {
).toHaveLength(2);
});
it('retries the native session generation floor read after a transient failure', async () => {
let floorReads = 0;
const invoke = vi.fn(async (command: string, payload?: unknown) => {
if (command === 'read_platform_account_session_generation') {
floorReads += 1;
if (floorReads === 1) {
throw new Error('runner not ready');
}
return 12;
}
if (
command === 'install_platform_account_session' ||
command === 'clear_platform_account_session'
) {
expect(payload).toEqual(
expect.objectContaining({ generation: expect.any(Number) }),
);
}
return null;
});
window.__TAURI__ = { core: { invoke } };
const firstGeneration = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'retry-floor-token',
);
await expect(
commitAuthenticatedPlatformSession(testAuthUser, firstGeneration),
).rejects.toThrow('runner not ready');
// 瞬时读取失败不能被缓存成永久失败:第二次登录必须重新读取并成功。
const secondGeneration = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'retry-floor-token',
);
await expect(
commitAuthenticatedPlatformSession(testAuthUser, secondGeneration),
).resolves.toEqual(expect.any(Number));
expect(floorReads).toBeGreaterThan(1);
expect(invoke).toHaveBeenLastCalledWith(
'install_platform_account_session',
expect.objectContaining({
userId: testAuthUser.id,
accessToken: 'retry-floor-token',
}),
);
});
it('does not let a stalled native install wedge the next login', async () => {
vi.useFakeTimers();
const installedTokens: string[] = [];
let releaseStalledInstall: (() => void) | null = null;
const invoke = vi.fn(async (command: string, payload?: unknown) => {
if (command === 'install_platform_account_session') {
installedTokens.push(
String((payload as { accessToken?: string })?.accessToken),
);
if (installedTokens.length === 1) {
await new Promise<void>((resolve) => {
releaseStalledInstall = resolve;
});
}
}
return null;
});
window.__TAURI__ = { core: { invoke } };
const stalledGeneration = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'stalled-token',
);
const stalled = commitAuthenticatedPlatformSession(
testAuthUser,
stalledGeneration,
);
await vi.advanceTimersByTimeAsync(0);
expect(installedTokens).toEqual(['stalled-token']);
const retryGeneration = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'retry-token',
);
const retry = commitAuthenticatedPlatformSession(
testAuthUser,
retryGeneration,
);
await vi.advanceTimersByTimeAsync(60_000);
expect(installedTokens).toEqual(['stalled-token', 'retry-token']);
await expect(retry).resolves.toEqual(expect.any(Number));
expect(
window.localStorage.getItem('genarrative.auth.access-token.v1'),
).toBe('retry-token');
// 迟到的旧 install 只影响它自己:Rust 按 generation 拒绝过期写入,
// 渲染层保持新会话为准。
releaseStalledInstall?.();
await expect(stalled).resolves.toBeNull();
expect(
window.localStorage.getItem('genarrative.auth.access-token.v1'),
).toBe('retry-token');
vi.useRealTimers();
});
it('adopts a login whose native session install finishes after the UI fence', async () => {
vi.useFakeTimers();
let releaseInstall: (() => void) | null = null;
const invoke = vi.fn(async (command: string) => {
if (command === 'install_platform_account_session') {
await new Promise<void>((resolve) => {
releaseInstall = resolve;
});
}
return null;
});
window.__TAURI__ = { core: { invoke } };
vi.spyOn(globalThis, 'fetch').mockImplementation(
async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/auth/refresh') {
return new Response('', { status: 401 });
}
if (url === '/api/auth/phone/login') {
return new Response(
JSON.stringify({
token: 'late-install-token',
user: { ...testAuthUser, loginMethod: 'phone' },
created: false,
referral: null,
}),
{ status: 200 },
);
}
throw new Error(`unexpected fetch ${url}`);
},
);
render(
React.createElement(AuthenticatedClient, null, ({ user }) =>
React.createElement('main', { 'aria-label': '已登录' }, user.id),
),
);
for (let i = 0; i < 5; i += 1) {
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
}
fireEvent.change(screen.getByLabelText('手机号'), {
target: { value: '13800000000' },
});
fireEvent.change(screen.getByLabelText('验证码'), {
target: { value: '123456' },
});
fireEvent.click(screen.getByRole('button', { name: '登录' }));
await act(async () => {
await vi.advanceTimersByTimeAsync(45_000);
});
expect(
screen.getByText('连接本地运行时超时,请重试或重启客户端'),
).not.toBeNull();
expect(screen.queryByLabelText('已登录')).toBeNull();
// 围栏只放弃等待:本地运行时确实装好会话后,界面必须跟着进工作区。
releaseInstall?.();
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(screen.queryByLabelText('已登录')).not.toBeNull();
expect(
window.localStorage.getItem('genarrative.auth.access-token.v1'),
).toBe('late-install-token');
vi.useRealTimers();
});
it('keeps the previous renderer session authoritative when replacement install is rejected', async () => {
const invoke = vi.fn(async (_command: string, payload?: unknown) => {
const userId = (payload as { userId?: string } | undefined)?.userId;
@@ -1835,6 +1835,146 @@ export function registerHomeProjectCreationTests() {
expect(screen.queryByText('自动创建测试结束')).toBeNull();
});
it('unblocks the home launcher when automatic creation never returns', async () => {
vi.useFakeTimers();
const automaticProjectPath = '/tmp/home-stalled-project';
const manifest = createGameCreationAppManifest(
'home-stalled-project',
'卡住的自动建项',
);
let resolveAutomaticProject:
| ((result: Record<string, unknown>) => void)
| null = null;
const invoke = vi.fn(async (command: string) => {
if (command === 'create_automatic_local_game_project') {
return await new Promise((resolve) => {
resolveAutomaticProject = resolve;
});
}
throw new Error(`unexpected invoke ${command}`);
});
window.__TAURI__ = { core: { invoke } };
renderLauncherAt('/?launcher', 'home', true);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
const promptInput = screen.getByLabelText('创作想法');
nativeClipboardMock.text = '做一个挂机游戏';
fireEvent.paste(promptInput);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
fireEvent.click(screen.getByRole('button', { name: '开启创作' }));
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(
(screen.getByRole('button', { name: '开启创作' }) as HTMLButtonElement)
.disabled,
).toBe(true);
// 超过兜底期限:首页入口必须解围,不能永远卡在"正在创建工作区"。
await act(async () => {
await vi.advanceTimersByTimeAsync(10 * 60_000);
});
expect(screen.getByText(/工作区创建超过 10 分钟/)).not.toBeNull();
expect(
(screen.getByRole('button', { name: '开启创作' }) as HTMLButtonElement)
.disabled,
).toBe(false);
// 解围不等于放弃底层创建,也不等于允许再建第二个工作区。
fireEvent.click(screen.getByRole('button', { name: '开启创作' }));
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(screen.getByText(/上一次工作区创建仍未返回/)).not.toBeNull();
expect(
invoke.mock.calls.filter(
([command]) => command === 'create_automatic_local_game_project',
),
).toHaveLength(1);
// 迟到的成功仍然要进项目:用户不该因为慢就丢掉这次创建。
await act(async () => {
resolveAutomaticProject?.({
projectPath: automaticProjectPath,
manifestPath: `${automaticProjectPath}/.agent/manifest.json`,
manifest,
});
await vi.advanceTimersByTimeAsync(0);
});
expect(screen.getByLabelText('项目开发工作台')).not.toBeNull();
cleanup();
vi.useRealTimers();
});
it('keeps the created workspace recoverable when design runtime setup fails', async () => {
const projectPath = '/tmp/home-design-mode-failure';
const manifest = createGameCreationAppManifest(
'home-design-mode-failure',
'策划初始化失败项目',
);
const supervisorHarness = createProjectSupervisorRuntimeHarness({
projectPath,
initialSessionExists: false,
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'create_automatic_local_game_project') {
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'set_design_agent_runtime_mode') {
throw new Error('design runtime unavailable');
}
if (command === 'inspect_local_project_directory') {
return {
exists: true,
isDirectory: true,
isGameCreatorProject: true,
isCocosProject: false,
godotProjectRoot: null,
};
}
if (command === 'get_local_game_manifest') {
return manifest;
}
return supervisorHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: supervisorHarness.listen },
};
renderLauncherAt('/?launcher', 'home', true);
// 做方案走立项链路:建项之后还要写策划运行时,这一步失败时项目目录已经存在。
fireEvent.click(screen.getByRole('button', { name: '做方案' }));
const promptInput = screen.getByLabelText('创作想法');
nativeClipboardMock.text = '做一个塔防游戏';
fireEvent.paste(promptInput);
await waitFor(() => {
expect(promptInput.textContent).toContain('做一个塔防游戏');
});
fireEvent.keyDown(promptInput, { key: 'Enter', code: 'Enter' });
expect(await screen.findByText('创建未完成,请重试')).not.toBeNull();
expect(screen.getByText(`已创建的工作区:${projectPath}`)).not.toBeNull();
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith('inspect_local_project_directory', {
projectPath,
});
});
fireEvent.click(screen.getByRole('button', { name: '打开已创建的工作区' }));
expect(await screen.findByLabelText('项目开发工作台')).not.toBeNull();
});
it.each([
['做方案', false],
['做方案', true],
@@ -1893,7 +1893,7 @@ export function registerProjectCommandTests() {
expect(
await screen.findByText(
'当前仅支持确认 project.index、project.status、project.checkpoint、project.diff、project.restore、project.export_package、project.export_list、file.list、file.read、memory.read、memory.write、memory.delete、asset.register、asset.list、task.list、task.create、task.update、agent.run_status、agent.kill、agent.retry、agent.resume、agent.delegate、agent.spawn_isolated、agent.schedule_ready、agent.audit、agent.trace_read、preview.status、preview.start、preview.validate、preview.open、preview.stop、canvas.project_sync、canvas.asset_import、canvas.asset_generate、canvas.export_import、conversation.read 和 conversation.write。',
'当前仅支持确认 project.index、project.status、project.bootstrap、project.checkpoint、project.diff、project.restore、project.export_package、project.export_list、file.list、file.read、memory.read、memory.write、memory.delete、asset.register、asset.list、task.list、task.create、task.update、agent.run_status、agent.kill、agent.retry、agent.resume、agent.delegate、agent.spawn_isolated、agent.schedule_ready、agent.audit、agent.trace_read、preview.status、preview.start、preview.validate、preview.open、preview.stop、canvas.project_sync、canvas.asset_import、canvas.asset_generate、canvas.export_import、conversation.read 和 conversation.write。',
),
).not.toBeNull();
expect(screen.queryByText('project.policy_write')).toBeNull();
@@ -1,5 +1,14 @@
# 踩坑与排障记录
## 2026-09-14 UI 超时围栏只能放弃等待,不能放弃结果;排队闸门不能无限等
- **现象**:登录/建项在 UI 上"超时"后报错,用户重试仍然无效;界面停在原页面,而后端/Runner 其实已经接受了这次操作(登录后本机登录态已装好、项目目录已建好)。
- **原因**:两个独立缺陷叠加。(1) `withAuthCheckTimeout` 一类围栏用 `Promise.race` 只让界面提前失败,底层 native mutation 仍在队列里跑;而队列尾是"无限等上一次完成"的串接,一次卡住的 invoke 会让之后每次登录/退出都排在它后面(故障注入:连续两次登录只产生 1 次 install 调用)。(2) `platformNativeGenerationFloorPromise``??=` 缓存 promise,一次瞬时读取失败被缓存成永久失败。
- **处理**:(1) 围栏超时后仍要有人接手结果——`AuthenticatedClient` 用尝试代次 + `currentPlatformSessionGeneration()` 判定,迟到成功才写回界面,绝不覆盖更新的尝试;(2) native 写入队列改成带解围期限的闸门(`PLATFORM_SESSION_NATIVE_MUTATION_ABANDONMENT_MS`)。普通队列"串行"看起来更安全,但本地会话写入的真正不变量在 Rust:`install_platform_session_in` / `clear_platform_session_in` 按 generation 单调拒绝更旧写入,所以渲染层只要保证新 generation 不被旧调用永久挡住即可;(3) 首页 `home-create``deadlineMs: null` 改为有兜底期限,到点用 `Promise.race` 返回提示字符串解围(**不要抛错**:首页 catch 会把错误统一压成「创建未完成,请重试」,反而丢掉"只是慢")而底层创建继续跑,迟到成功照常进项目;已建好的工作区要登记最近项目并留「打开已创建的工作区」入口。
- **易错点**:失败分支里不要用**初始** operation 去覆盖已经带上 `scope.projectPath` 的状态——`transitionClientOperation(初始 operation, ...)` 会把内层写好的路径丢掉,导致"项目已建好但用户拿不到"。看门狗解围后仍要保留"底层创建未返回"标记:放行会真的建出第二个工作区;但这条只挡"再建一个",不能挡打开已有项目。
- **验证**`apps/ai-game-creator-shell/tests/appSurface.test.ts``auth.suite.ts` 的 stalled install / floor 瞬时失败 / 围栏后迟到安装;`home.suite.ts` 的建项看门狗与设计运行时初始化失败后的恢复入口)。
- **关联**`apps/ai-game-creator-shell/src/services/platformSession.ts``src/app/AuthenticatedClient.tsx``src/features/app-shell/useHomeProjectCreation.ts``src-tauri/src/platform_session.rs``docs/【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md`
## 2026-09-13 Cocos 操作必须核对实际回执与引擎就绪状态
- named pipe 使用真实换行分帧;测试客户端若写入字面量反斜杠 n,服务端不会执行请求。不能仅凭这类超时推断 Scene WebView 卡死,更不能重放不确定写操作。
@@ -59,6 +59,15 @@
- 稳定版基础切换里程碑已完成;后续入口只允许复用该合同,不再新增 component-level busy/ref 状态机。
- Planning/DirectProject/资源生成/预览已有各自 durable operation 或 request scope;本轮只补统一投影与身份校验,不重写其持久化账本。
### 生命周期残留收口(本轮)
上一轮在 master`0f829cd25`)上做故障注入复现出四类"每走一步都卡住"的残留,本轮按"超时后真正隔离并核对底层操作"收口,不再新增 operation 字段:
1. **本地会话写入队列不再被卡死**`platformSession.ts` 的 native mutation 队列改为带"解围期限"的闸门(60s)。Rust `install_platform_session_in` / `clear_platform_session_in` 本来就按 generation 单调校验(更旧的 install/clear 一律拒绝),所以渲染层只需保证新 generation 不被旧调用无限挡住;迟到的旧写入由 Rust 拒绝。
2. **generation floor 读取失败可重试**`reserveNativePlatformSessionGeneration` 不再把一次瞬时失败缓存成永久失败(原先 `??=` 缓存了已 reject 的 promise,导致同一次渲染进程内后续登录/退出全部失败)。
3. **登录围栏超时后的迟到结果必须落地**:UI 的 45s 围栏只放弃等待,不再放弃结果。本地运行时确实装好会话时,界面跟着进入工作区,避免"后端已登录、前端停在登录页"。
4. **首页自动建项有兜底期限与恢复入口**`home-create``deadlineMs: null` 改为 10 分钟兜底期限;到点后首页入口解围(底层创建继续在后台跑,迟到成功照常进项目),并把已建好的工作区登记进最近项目、在首页给出「打开已创建的工作区」入口。外层 catch 不再用初始 operation 覆盖内层已写入的 `scope.projectPath`;底层创建未返回期间只挡"再建一个",不挡打开已有项目。
## 现役边界审计
以下能力在本次切换前已经具备 durable 恢复或失败关闭合同,因此本轮按现有实现接入统一投影,不重复重写:
@@ -82,4 +91,17 @@
| 最近项目逐行刷新 | `recentProjectsModel.test.ts`、home appSurface |
| dev-stack 身份 | `scripts/dev.test.ts``start-dev-stack.test.ts`、端口 marker 检查 |
| 本地恢复边界 | 现有 manifest/runtime/resource recovery tests;未确认外部副作用不自动重放 |
| 超时隔离与迟到结果 | `auth.suite.ts`stalled native install 不阻塞后续登录、floor 瞬时失败可重试、围栏超时后迟到安装落地)、`home.suite.ts`(建项看门狗解围并保留迟到成功、设计运行时初始化失败后保留工作区并可打开) |
| 完整流程 | AGC appSurface、开发栈 smoke;真实 Provider/安装包另行记录 |
### 本轮门禁执行记录
- `npm --prefix apps/ai-game-creator-shell run typecheck`(含 skill-pack、check-config)通过。
- `apps/ai-game-creator-shell/tests/appSurface.test.ts` 428/428 通过(含本轮新增 5 个故障注入回归)。
- 定向:`clientHttp``clientApi``clientOperation``recentProjectsModel``clientRuntimeErrorBoundary``sessionPreview``start-dev-stack``dev-port``start-tauri-dev` 全部通过。
### 待验证(本轮未确认,不作为结论)
- 原生(真实 Runner/IPC)与真实 Provider 下的同一批时序未执行:本轮结论来自 deterministic surface 与 mock 故障注入。
- `src-tauri/src/project/bootstrap.rs``npm install` 之前读取 `package-lock.json` 计算 `lockSha256`,安装后仍使用旧字节;疑似只影响审计准确性,未复现、未修改。
- 同 PID 下的写入 advisory guard`write_lock.rs``bypassed_same_process`)是否会放过并行写,尚未排除误报。