From 7092689b36da1fb6d9ebed30e617486cf16f9a09 Mon Sep 17 00:00:00 2001
From: kdletters
Date: Mon, 14 Sep 2026 14:49:28 +0800
Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E5=8F=A3=20AGC=20=E7=94=9F=E5=91=BD?=
=?UTF-8?q?=E5=91=A8=E6=9C=9F=E8=B6=85=E6=97=B6=E9=9A=94=E7=A6=BB=E4=B8=8E?=
=?UTF-8?q?=E5=BB=BA=E9=A1=B9=E6=81=A2=E5=A4=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## 变更内容
在 master(0f829cd25)上用故障注入复现出四类"每走一步都会卡住"的生命周期残留,本轮按"超时后真正隔离并核对底层操作"收口,不再新增 operation 字段:
- 本地会话写入队列从"无限等上一次完成"改为带解围期限的闸门(60s)。Rust 侧 install/clear 本来就按 generation 单调拒绝更旧写入,所以渲染层只需保证新 generation 不被卡死的调用永久挡住;
- generation floor 读取不再把一次瞬时失败缓存成永久失败(原先 `??=` 缓存了已 reject 的 promise,导致同一渲染进程内后续登录/退出全部失败);
- 登录 UI 的 45 秒围栏只放弃等待、不放弃结果:本地运行时确实装好会话时界面跟随进入工作区,且迟到结果不会覆盖更新的登录尝试;
- 首页自动建项从 `deadlineMs: null` 改为 10 分钟兜底期限:到点解围并提示(底层创建继续在后台跑,迟到成功照常进项目),失败时保留 `scope.projectPath`、登记最近项目并新增「打开已创建的工作区」入口;底层创建未返回期间只挡"再建一个",不挡打开已有项目;
- 同步 `project.bootstrap` 权限文案断言、生命周期技术方案状态与 shared pitfalls。
## 验证
- `npm --prefix apps/ai-game-creator-shell run typecheck`(含 skill-pack、check-config)
- `vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts`(428/428,含新增 5 个故障注入回归;`project.bootstrap` 文案断言在此前 master 上确定性失败)
- 定向 `clientHttp`、`clientApi`、`clientOperation`、`recentProjectsModel`、`clientRuntimeErrorBoundary`、`sessionPreview`、`start-dev-stack`、`dev-port`、`start-tauri-dev`(全部通过)
- `npm run check:doc-index`、`npm run check:encoding`、`git diff --check`
原生 Runner/IPC 与真实 Provider 下的同一批时序未执行,本轮结论来自 deterministic surface 与 mock 故障注入。
Co-authored-by: DotCraft <273930855+dotcraft-ai@users.noreply.github.com>
---
.../src/app/AuthenticatedClient.tsx | 50 ++++-
.../features/app-shell/WorkspaceLauncher.tsx | 2 +
.../app-shell/useHomeProjectCreation.ts | 206 ++++++++++++++----
.../src/services/platformSession.ts | 50 ++++-
.../src/view/home/index.tsx | 20 ++
.../tests/appSurface/auth.suite.ts | 179 +++++++++++++++
.../tests/appSurface/home.suite.ts | 140 ++++++++++++
.../appSurface/project-commands.suite.ts | 2 +-
docs/project-memory/shared-memory/pitfalls.md | 9 +
...】AGC客户端稳定版生命周期大切换-2026-09-14.md | 22 ++
10 files changed, 616 insertions(+), 64 deletions(-)
diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx
index a91af7ff4..2c9fe3834 100644
--- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx
+++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx
@@ -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;
}
diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx
index c9061de45..58a188e6f 100644
--- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx
+++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx
@@ -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);
diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts
index 56e0413ca..61fb8681a 100644
--- a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts
+++ b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts
@@ -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(
- '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((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(
+ '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,
diff --git a/apps/ai-game-creator-shell/src/services/platformSession.ts b/apps/ai-game-creator-shell/src/services/platformSession.ts
index ba708b774..8f40a9ef6 100644
--- a/apps/ai-game-creator-shell/src/services/platformSession.ts
+++ b/apps/ai-game-creator-shell/src/services/platformSession.ts
@@ -43,7 +43,16 @@ let platformSessionOperation: ClientOperation<
'auth-transition',
{ userId: string | null }
> | null = null;
-let platformSessionNativeMutationTail: Promise = 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 = Promise.resolve();
const platformSessionRefreshListeners =
new Set();
const platformSessionGenerationListeners =
@@ -104,15 +113,34 @@ async function clearNativePlatformSession(generation: number) {
await invoke('clear_platform_account_session', { generation });
}
+function waitForNativeMutationAbandonment(
+ settled: Promise,
+ timeoutMs: number,
+) {
+ let timerId: number | undefined;
+ const abandoned = new Promise((resolve) => {
+ timerId = window.setTimeout(resolve, timeoutMs);
+ });
+ return Promise.race([settled, abandoned]).finally(() => {
+ if (timerId !== undefined) {
+ window.clearTimeout(timerId);
+ }
+ });
+}
+
function enqueuePlatformSessionNativeMutation(
operation: () => Promise,
+ abandonmentMs = PLATFORM_SESSION_NATIVE_MUTATION_ABANDONMENT_MS,
): Promise {
- 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();
}
diff --git a/apps/ai-game-creator-shell/src/view/home/index.tsx b/apps/ai-game-creator-shell/src/view/home/index.tsx
index 6e6a82824..d1470cb25 100644
--- a/apps/ai-game-creator-shell/src/view/home/index.tsx
+++ b/apps/ai-game-creator-shell/src/view/home/index.tsx
@@ -113,6 +113,10 @@ type HomeViewProps = {
startMode: ProjectStartMode,
) => Promise;
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}
) : null}
+ {recoverableCreatedProjectPath && !creationBusy ? (
+
+ 已创建的工作区:{recoverableCreatedProjectPath}
+
+
+ ) : null}
{
+ 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((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((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;
diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts
index afd12de06..8f09ca84c 100644
--- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts
+++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts
@@ -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) => 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) => {
+ 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],
diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts
index a7dc65789..79b577243 100644
--- a/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts
+++ b/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts
@@ -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();
diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md
index a72200ff0..4df507394 100644
--- a/docs/project-memory/shared-memory/pitfalls.md
+++ b/docs/project-memory/shared-memory/pitfalls.md
@@ -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 卡死,更不能重放不确定写操作。
diff --git a/docs/【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md b/docs/【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md
index bf3a532ff..f5abedfa0 100644
--- a/docs/【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md
+++ b/docs/【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md
@@ -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`)是否会放过并行写,尚未排除误报。