From 21efc4cdc42a468440d572460f7e166c53f0a3f6 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 14 Sep 2026 12:50:51 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BB=BA=E7=AB=8B=20AGC=20=E7=A8=B3=E5=AE=9A?= =?UTF-8?q?=E7=89=88=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E5=9F=BA=E7=A1=80?= =?UTF-8?q?=E5=90=88=E5=90=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 ClientOperation 状态、身份和 stale-result 规则 让认证 refresh 与首页创建记录统一 operation phase 和 scope 为 dev-stack 与 AGC Vite marker 增加工作树和实例身份门禁 补充稳定版切换主规范、里程碑计划和开发运维踩坑文档 --- .../scripts/start-dev-stack.mjs | 23 +++- .../features/app-shell/WorkspaceLauncher.tsx | 3 +- .../app-shell/useHomeProjectCreation.ts | 50 ++++++++- .../src/services/clientAuth.ts | 33 ++++++ .../src/services/clientOperation.ts | 105 ++++++++++++++++++ .../tests/clientApi.test.ts | 9 +- .../tests/clientOperation.test.ts | 42 +++++++ .../tests/start-dev-stack.test.ts | 44 ++++++-- apps/ai-game-creator-shell/vite.config.ts | 2 + docs/README.md | 1 + ...】AGC客户端稳定版生命周期基础切换-2026-09-14.md | 33 ++++++ ...AGC客户端稳定版生命周期基础切换-2026-09-14.md | 33 ++++++ docs/project-memory/shared-memory/pitfalls.md | 7 ++ ...发运维】本地开发验证与生产运维-2026-05-15.md | 4 +- ...】AGC客户端稳定版生命周期大切换-2026-09-14.md | 70 ++++++++++++ scripts/dev.mjs | 10 ++ scripts/dev.test.ts | 5 + 17 files changed, 460 insertions(+), 14 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/services/clientOperation.ts create mode 100644 apps/ai-game-creator-shell/tests/clientOperation.test.ts create mode 100644 docs/project-memory/plans/【实施计划】AGC客户端稳定版生命周期基础切换-2026-09-14.md create mode 100644 docs/project-memory/plans/【里程碑】AGC客户端稳定版生命周期基础切换-2026-09-14.md create mode 100644 docs/【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md diff --git a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs index 9c7e9ee8d..e85700ec9 100644 --- a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs +++ b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs @@ -84,6 +84,7 @@ function resolveBackendTargetsFromState( requireAgcBackend = false, expectedDatabase = backendDatabase, expectedSpacetimeDataDir = backendSpacetimeDataDir, + expectedRepoRoot = repoRoot, fallbackApiTarget = defaultApiTarget, } = {}, ) { @@ -101,7 +102,25 @@ function resolveBackendTargetsFromState( const hasMatchingDataDir = Boolean(spacetimeDataDir) && spacetimeDataDir === resolve(expectedSpacetimeDataDir); - const hasMatchingBackend = hasMatchingDatabase && hasMatchingDataDir; + const instanceId = + typeof state?.instanceId === 'string' ? state.instanceId.trim() : ''; + const hasMatchingRepoRoot = + typeof state?.repoRoot === 'string' && + resolve(state.repoRoot) === resolve(expectedRepoRoot); + const hasMatchingInstance = + Boolean(instanceId) && + [apiServer, spacetime, bgfilterWorker] + .filter(Boolean) + .every( + (service) => + service.repoRoot && + resolve(service.repoRoot) === resolve(expectedRepoRoot) && + service.instanceId === instanceId, + ); + const hasMatchingBackend = + hasMatchingDatabase && + hasMatchingDataDir && + (!requireAgcBackend || (hasMatchingRepoRoot && hasMatchingInstance)); const canReuseState = !requireAgcBackend || hasMatchingBackend; const apiUrl = canReuseState && isActive(apiServer) && apiServer.url @@ -127,6 +146,8 @@ function resolveBackendTargetsFromState( spacetimeDataDir, hasMatchingDatabase, hasMatchingDataDir, + hasMatchingRepoRoot, + hasMatchingInstance, hasMatchingBackend, }; } 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 913ecac2f..c9061de45 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 @@ -93,6 +93,7 @@ export function WorkspaceLauncherShell({ startGameFromApprovedGdd, createHomeDraftAutomatically, openProject, + homeCreationBusy, } = homeProject; const switchedToGameRuntime = gameRuntimeSwitch !== null && @@ -522,7 +523,7 @@ export function WorkspaceLauncherShell({ onStatusChange={setStatus} recentProjectRows={recentProjectRows} onCreateDraftAutomatically={createHomeDraftAutomatically} - creationBusy={homeProject.projectAction === 'creating'} + creationBusy={homeCreationBusy} 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 8ecc89fb7..56e0413ca 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 @@ -26,6 +26,11 @@ import type { TauriInvoke, UploadLocalAssetResult, } from '../../app/types'; +import { + type ClientOperation, + createClientOperation, + transitionClientOperation, +} from '../../services/clientOperation'; import type { HomeAttachmentDraft, HomeCreationType, @@ -117,6 +122,21 @@ export function useHomeProjectCreation({ (state) => state.reset, ); const approvedGddStartInFlightRef = useRef(false); + const [homeCreationOperation, setHomeCreationOperation] = + useState | null>(null); + const homeCreationOperationRef = useRef(homeCreationOperation); + homeCreationOperationRef.current = homeCreationOperation; + + function homeCreationIsBusy() { + const phase = homeCreationOperationRef.current?.phase; + return phase === 'network' || phase === 'runner' || phase === 'project'; + } /** * 进项目流程的代次。 * @@ -644,13 +664,19 @@ export function useHomeProjectCreation({ startMode: ProjectStartMode, options: { suggestName: boolean }, ) { - if (projectActionRef.current) { + if (projectActionRef.current || homeCreationIsBusy()) { return '已有项目操作进行中,请稍候'; } const invoke = resolveTauriInvoke(); if (!invoke) { throw new Error('需要在陶泥儿客户端内运行'); } + const operation = createClientOperation( + 'home-create', + { draft, startMode }, + { deadlineMs: null, cancellable: false }, + ); + setHomeCreationOperation(transitionClientOperation(operation, 'network')); // This action is owned by WorkspaceLauncher rather than HomeView. The // launcher survives navigation, so unmounting the home page cannot release // the guard while project creation or first-turn import is still running. @@ -668,6 +694,11 @@ export function useHomeProjectCreation({ planning: startMode === 'planning', }, ); + setHomeCreationOperation( + transitionClientOperation(operation, 'project', { + scope: { projectPath: result.projectPath }, + }), + ); try { await enterCreatedHomeProject( invoke, @@ -677,15 +708,30 @@ export function useHomeProjectCreation({ draft.attachments, startMode, ); + setHomeCreationOperation( + transitionClientOperation(operation, 'success', { + scope: { projectPath: result.projectPath }, + }), + ); setStatus('已创建工作区,正在开始智能创作'); return '已创建工作区并进入项目开发'; } catch (error) { + 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'), + ); + throw error; } finally { if (projectActionRef.current === 'creating') { projectActionRef.current = null; @@ -802,6 +848,8 @@ export function useHomeProjectCreation({ setAgentResults, projectAction, projectBusy: projectAction !== null, + homeCreationOperation, + homeCreationBusy: homeCreationIsBusy(), pendingNonEmptyProject, resetLauncherHomeDraft, startGameFromApprovedGdd, diff --git a/apps/ai-game-creator-shell/src/services/clientAuth.ts b/apps/ai-game-creator-shell/src/services/clientAuth.ts index a529ef10b..54c4c27e8 100644 --- a/apps/ai-game-creator-shell/src/services/clientAuth.ts +++ b/apps/ai-game-creator-shell/src/services/clientAuth.ts @@ -20,6 +20,11 @@ import { getClientServerBaseUrl, readClientHttpResponseText, } from './clientHttp'; +import { + type ClientOperation, + createClientOperation, + transitionClientOperation, +} from './clientOperation'; const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1'; @@ -55,6 +60,14 @@ export function clearStoredAuthAccessToken() { } const clientAuthRefreshPromises = new Map>(); +const clientAuthRefreshOperations = new Map< + string, + ClientOperation<'auth-refresh', { apiBaseUrl: string }> +>(); + +export function getClientAuthRefreshOperation(apiBaseUrl: string) { + return clientAuthRefreshOperations.get(apiBaseUrl) ?? null; +} const CLIENT_AUTH_NETWORK_ERROR_MESSAGE = '无法连接登录服务,请确认配套后端或 API 代理已启动后重试'; @@ -187,6 +200,15 @@ export async function refreshClientAuthAccessToken( ) { const current = clientAuthRefreshPromises.get(apiBaseUrl); if (current) return current; + const operation = createClientOperation( + 'auth-refresh', + { apiBaseUrl }, + { scope: { apiBaseUrl }, deadlineMs: 15_000 }, + ); + clientAuthRefreshOperations.set( + apiBaseUrl, + transitionClientOperation(operation, 'network'), + ); const refreshPromise = requestAuthJson( '/api/auth/refresh', { method: 'POST' }, @@ -194,9 +216,20 @@ export async function refreshClientAuthAccessToken( { skipAuth: true, apiBaseUrl }, ) .then((response) => { + clientAuthRefreshOperations.set( + apiBaseUrl, + transitionClientOperation(operation, 'success'), + ); setStoredAuthAccessToken(response.token); return response.token; }) + .catch((error) => { + clientAuthRefreshOperations.set( + apiBaseUrl, + transitionClientOperation(operation, 'retryable-failure'), + ); + throw error; + }) .finally(() => { if (clientAuthRefreshPromises.get(apiBaseUrl) === refreshPromise) { clientAuthRefreshPromises.delete(apiBaseUrl); diff --git a/apps/ai-game-creator-shell/src/services/clientOperation.ts b/apps/ai-game-creator-shell/src/services/clientOperation.ts new file mode 100644 index 000000000..d4d03bf79 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/clientOperation.ts @@ -0,0 +1,105 @@ +export type ClientOperationPhase = + | 'idle' + | 'network' + | 'runner' + | 'project' + | 'success' + | 'retryable-failure' + | 'unknown'; + +export type ClientOperationScope = { + projectPath?: string; + apiBaseUrl?: string; + sessionGeneration?: number; +}; + +export type ClientOperation< + TKind extends string = string, + TPayload = unknown, +> = { + operationId: string; + requestId: string; + kind: TKind; + phase: ClientOperationPhase; + startedAt: number; + deadlineAt: number | null; + scope: ClientOperationScope; + payload: TPayload; + cancellable: boolean; + cancelled: boolean; +}; + +let operationSequence = 0; + +function randomOperationPart() { + try { + return crypto.randomUUID(); + } catch { + operationSequence += 1; + return `${Date.now().toString(36)}-${operationSequence.toString(36)}`; + } +} + +export function createClientOperation( + kind: TKind, + payload: TPayload, + options: { + scope?: ClientOperationScope; + deadlineMs?: number | null; + cancellable?: boolean; + } = {}, +): ClientOperation { + const startedAt = Date.now(); + const deadlineMs = options.deadlineMs ?? null; + return { + operationId: `op-${randomOperationPart()}`, + requestId: `req-${randomOperationPart()}`, + kind, + phase: 'idle', + startedAt, + deadlineAt: + deadlineMs === null ? null : startedAt + Math.max(1, deadlineMs), + scope: { ...options.scope }, + payload, + cancellable: options.cancellable ?? true, + cancelled: false, + }; +} + +export function transitionClientOperation( + operation: ClientOperation, + phase: ClientOperationPhase, + patch: Partial< + Pick, 'scope' | 'payload'> + > = {}, +) { + return { + ...operation, + ...patch, + scope: patch.scope + ? { ...operation.scope, ...patch.scope } + : operation.scope, + phase, + }; +} + +export function cancelClientOperation( + operation: ClientOperation, +) { + return { ...operation, phase: 'unknown' as const, cancelled: true }; +} + +export function isCurrentClientOperation( + operation: ClientOperation | null | undefined, + operationId: string, +) { + return Boolean( + operation && operation.operationId === operationId && !operation.cancelled, + ); +} + +export function clientOperationCanRetry( + operation: ClientOperation | null | undefined, +) { + return operation?.phase === 'retryable-failure'; +} diff --git a/apps/ai-game-creator-shell/tests/clientApi.test.ts b/apps/ai-game-creator-shell/tests/clientApi.test.ts index 338768a99..7b31137a0 100644 --- a/apps/ai-game-creator-shell/tests/clientApi.test.ts +++ b/apps/ai-game-creator-shell/tests/clientApi.test.ts @@ -6,7 +6,10 @@ import { requestClientApi, setStoredAuthAccessToken, } from '../src/services/clientApi'; -import { refreshClientAuthAccessToken } from '../src/services/clientAuth'; +import { + getClientAuthRefreshOperation, + refreshClientAuthAccessToken, +} from '../src/services/clientAuth'; import { beginPlatformSessionTransition, commitAuthenticatedPlatformSession, @@ -169,6 +172,10 @@ it('响应体卡住超时后,下一次续期会重新发起请求', async () = const firstAssertion = expect(first).rejects.toThrow(); await vi.advanceTimersByTimeAsync(15_000); await firstAssertion; + expect(getClientAuthRefreshOperation('http://localhost:3000')).toMatchObject({ + kind: 'auth-refresh', + phase: 'retryable-failure', + }); const second = refreshClientAuthAccessToken('http://localhost:3000'); const secondAssertion = expect(second).rejects.toThrow(); diff --git a/apps/ai-game-creator-shell/tests/clientOperation.test.ts b/apps/ai-game-creator-shell/tests/clientOperation.test.ts new file mode 100644 index 000000000..ccb7d8b9b --- /dev/null +++ b/apps/ai-game-creator-shell/tests/clientOperation.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; + +import { + cancelClientOperation, + clientOperationCanRetry, + createClientOperation, + isCurrentClientOperation, + transitionClientOperation, +} from '../src/services/clientOperation'; + +describe('AGC client operation contract', () => { + it('keeps operation/request identity and deadline across phase transitions', () => { + const operation = createClientOperation( + 'home-create', + { prompt: '做一个游戏' }, + { scope: { projectPath: 'C:\\games\\demo' }, deadlineMs: 15_000 }, + ); + const network = transitionClientOperation(operation, 'network'); + const project = transitionClientOperation(network, 'project', { + scope: { projectPath: 'C:\\games\\created' }, + }); + + expect(project.operationId).toBe(operation.operationId); + expect(project.requestId).toBe(operation.requestId); + expect(project.deadlineAt).toBe(operation.startedAt + 15_000); + expect(project.scope.projectPath).toBe('C:\\games\\created'); + expect(project.cancellable).toBe(true); + expect(isCurrentClientOperation(project, operation.operationId)).toBe(true); + }); + + it('marks cancellation as stale and does not make it retryable', () => { + const operation = createClientOperation('auth-refresh', null); + const failed = transitionClientOperation(operation, 'retryable-failure'); + expect(clientOperationCanRetry(failed)).toBe(true); + const cancelled = cancelClientOperation(failed); + expect(cancelled.cancelled).toBe(true); + expect(isCurrentClientOperation(cancelled, operation.operationId)).toBe( + false, + ); + expect(clientOperationCanRetry(cancelled)).toBe(false); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts b/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts index fb4534a86..4edf0f708 100644 --- a/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts +++ b/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts @@ -36,26 +36,30 @@ const ownedBackend = async () => ({ }); function backendState(spacetimeDataDir?: string, includeBgfilterWorker = true) { + const instanceId = 'fixture-instance'; + const service = (url: string) => ({ + status: 'running', + url, + repoRoot: resolve('.'), + instanceId, + }); return { schemaVersion: spacetimeDataDir ? 2 : 1, + repoRoot: resolve('.'), + instanceId, database: expectedDatabase, updatedAt: '', ...(spacetimeDataDir ? { spacetimeDataDir } : {}), services: { 'api-server': { - status: 'running', - url: 'http://127.0.0.1:8082', + ...service('http://127.0.0.1:8082'), }, spacetime: { - status: 'running', - url: 'http://127.0.0.1:3101', + ...service('http://127.0.0.1:3101'), }, ...(includeBgfilterWorker ? { - 'bgfilter-worker': { - status: 'running', - url: 'http://127.0.0.1:8083', - }, + 'bgfilter-worker': service('http://127.0.0.1:8083'), } : {}), }, @@ -113,6 +117,30 @@ describe('AI 游戏创作配套后端复用门禁', () => { expect(matching.bgfilterWorkerUrl).toBe('http://127.0.0.1:8083'); }); + test('旧状态缺少 repoRoot 或 instanceId 时拒绝复用', () => { + const state = backendState(expectedDataDir); + delete state.repoRoot; + delete state.instanceId; + for (const service of Object.values(state.services)) { + if (service) { + delete service.repoRoot; + delete service.instanceId; + } + } + const targets = resolveBackendTargetsFromState(state, { + requireAgcBackend: true, + expectedDatabase, + expectedSpacetimeDataDir: expectedDataDir, + }); + + expect(targets.hasMatchingDatabase).toBe(true); + expect(targets.hasMatchingDataDir).toBe(true); + expect(targets.hasMatchingRepoRoot).toBe(false); + expect(targets.hasMatchingInstance).toBe(false); + expect(targets.hasMatchingBackend).toBe(false); + expect(targets.apiUrl).toBe(''); + }); + test('worker 缺失或未 ready 时不允许复用后端', async () => { const isReady = vi.fn(async (_url: string) => true); diff --git a/apps/ai-game-creator-shell/vite.config.ts b/apps/ai-game-creator-shell/vite.config.ts index 71b012d81..6e68153ba 100644 --- a/apps/ai-game-creator-shell/vite.config.ts +++ b/apps/ai-game-creator-shell/vite.config.ts @@ -63,6 +63,8 @@ export default defineConfig({ JSON.stringify({ schemaVersion: 1, app: 'ai-game-creator-shell', + repoRoot, + processId: process.pid, port: server.config.server.port, apiTarget, }), diff --git a/docs/README.md b/docs/README.md index da3d178e0..b50d9c401 100644 --- a/docs/README.md +++ b/docs/README.md @@ -31,6 +31,7 @@ - [AI 游戏创作智能体 App 实施计划](./technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md):当前 DirectProject、受控语义工具、UI workflow、资源和运行时合同。 - [AGC 异步操作可恢复闭环](./【技术方案】AGC异步操作可恢复闭环-2026-09-14.md):认证响应体、最近项目检查和首页自动创建的超时、逐项恢复与跨页防重合同。 +- [AGC 客户端稳定版生命周期大切换](./【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md):统一 operation、认证/Runner、项目入口、本地恢复和 dev-stack 身份边界。 - [策划会话 Runtime V2 接入与旧链路退役方案](./technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md):新单 Agent 策划会话、GDD 策略、未来 MCP/Skill 兼容插槽、阶段任务与退役验收合同。 - [DirectProject Codex 原始历史与异常恢复](<./technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md>):原始 Responses item 持久化、线程注入与异常回合收尾。 - [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。 diff --git a/docs/project-memory/plans/【实施计划】AGC客户端稳定版生命周期基础切换-2026-09-14.md b/docs/project-memory/plans/【实施计划】AGC客户端稳定版生命周期基础切换-2026-09-14.md new file mode 100644 index 000000000..7c2ab91f2 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】AGC客户端稳定版生命周期基础切换-2026-09-14.md @@ -0,0 +1,33 @@ +# 【实施计划】AGC 客户端稳定版生命周期基础切换 + +| 字段 | 值 | +| --- | --- | +| Milestone | `docs/project-memory/plans/【里程碑】AGC客户端稳定版生命周期基础切换-2026-09-14.md` | +| Status | ready | +| Owner | Codex | + +## 修改边界 + +- 允许修改:AGC client operation/auth/home、dev-stack snapshot/Vite marker、对应测试和稳定版主规范。 +- 明确不修改:公开 API/schema、项目 manifest/runtime durable schema、旧项目文件和真实 Provider 服务。 + +## 实现顺序 + +1. 补 ClientOperation 类型、转移函数和 stale/cancel 测试。 +2. 让 auth refresh 保留 operation 投影,首页自动创建记录 draft、phase 和项目 scope。 +3. 在 dev-stack snapshot 与 Vite marker 记录 instanceId、repoRoot、processId、port、dataDir,并补复用门禁。 +4. 运行 typecheck、定向测试、dev-stack 测试、编码、文档索引和 diff 检查。 + +## 验证命令 + +1. `npm --prefix apps/ai-game-creator-shell exec -- vitest run tests/clientOperation.test.ts tests/clientApi.test.ts tests/clientHttp.test.ts tests/recentProjectsModel.test.ts --reporter=dot` +2. `npm --prefix apps/ai-game-creator-shell run typecheck` +3. `npm --prefix apps/ai-game-creator-shell exec -- vitest run tests/start-dev-stack.test.ts tests/dev-port.test.ts --reporter=dot` +4. `npm --prefix . exec -- vitest run scripts/dev.test.ts --reporter=dot` +5. `npm run check:doc-index` +6. `npm run check:encoding` +7. `git diff --check` + +## 风险与回滚点 + +旧 dev-stack 文件缺少 instanceId 时必须拒绝 AGC 后端复用而不是猜测;若 marker 字段导致既有 Vite 启动链不兼容,回滚只撤销 identity 投影,不回退已完成的 body timeout/home/recent 修复。 diff --git a/docs/project-memory/plans/【里程碑】AGC客户端稳定版生命周期基础切换-2026-09-14.md b/docs/project-memory/plans/【里程碑】AGC客户端稳定版生命周期基础切换-2026-09-14.md new file mode 100644 index 000000000..71eb02a07 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC客户端稳定版生命周期基础切换-2026-09-14.md @@ -0,0 +1,33 @@ +# 【里程碑】AGC 客户端稳定版生命周期基础切换 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | ready | +| Date | 2026-09-14 | +| Parent Spec | `docs/【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md` | + +## 目标 + +落地 ClientOperation 基础合同,接入认证 refresh 与首页自动创建,并把 dev-stack snapshot/marker 的身份字段补齐。 + +## 范围 + +客户端 operation 类型与 stale 判定、认证 refresh 投影、首页创建 phase 投影、dev-stack instanceId/repoRoot/processId/port/dataDir 记录与验证测试。 + +## 不在范围内 + +本轮不删除持久化迁移、Runtime/资源账本或公开后端接口;不重写 Planning V2、DirectProject 和资源生成的 durable executor。 + +## 验收标准 + +- [ ] ClientOperation identity、phase、deadline、scope 和取消状态有定向测试。 +- [ ] 认证 refresh 和首页创建暴露 operation 投影,切页不会丢失 operation。 +- [ ] dev-stack snapshot 与 Vite marker 带工作树/进程身份字段。 +- [ ] 类型、定向测试、dev-stack 测试、编码和 diff 检查通过。 + +## 证据要求 + +- 自动化:clientOperation、clientAuth/clientApi、home、dev-stack 定向测试。 +- 运行时:只读端口归属探测;必要时 AGC Vite marker smoke。 +- 边界:旧状态缺字段拒绝复用,迟到 operation 不能写回,取消不自动重放。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 50e67a1b8..a72200ff0 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5495,3 +5495,10 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **真机判据**:点进任一栏目(或展开「所有资源」)时,**每一张**卡片都从它那一摞的位置/尺寸位移并缩放到自己的位置,而不只是总览里那 3 张;同一栏目里不同类型的两摞各自飞各自的卡。`prefers-reduced-motion: reduce` 下仍不播放动画(口径未改)。 - **已知未覆盖**:① 子画布内直接切到另一个栏目(分页画布换栏目)时目标栏目的卡片在该次 `begin` 时未渲染、拿不到任何 First,整段转场仍按旧口径跳过(`play` 的 `!captured.entries.size` 早退),本次未改;② 真机动画观感由浏览器渲染,jsdom 只覆盖几何与调用契约,需要按上面那条判据人工确认一次。 - **关联**:`apps/ai-game-creator-shell/src/view/project-development/resourceBookController.ts`(`begin` 的堆锚点 / `syncNodes` 的合成 First)、`resourceBookLayout.ts`(`allOpen` 列号)、`index.tsx`(宿主 `data-resource-book-stack-*`)、`apps/ai-game-creator-shell/tests/{resourceBookController,resourceBookLayout}.test.ts`、`tests/appSurface/project-development.suite.ts`、`docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md`。 + +## 稳定版 AGC 复用开发栈必须核对 instance identity + +- 现象:端口和 `/healthz` 都正常,但 AGC 连接了另一个 worktree 的 API、SpacetimeDB 或旧 Vite,表现为登录、项目列表、Runner 状态与当前代码不一致。 +- 原因:健康检查只能证明“有服务响应”,不能证明服务属于当前工作树;旧 `.app/dev-stack.json` 可能没有当前 `repoRoot`、`instanceId` 和服务级 dataDir 身份。 +- 处理:先读取 `.app/dev-stack.json`,核对顶层 `repoRoot + instanceId`,再核对服务 `repoRoot + instanceId + dataDir + pid + port`;AGC Vite marker 还必须带 `repoRoot + processId + port`。任何字段缺失或不匹配都拒绝静默复用,改为启动当前工作树自己的服务或明确提示清理。 +- 验证:`scripts/dev.test.ts`、`apps/ai-game-creator-shell/tests/start-dev-stack.test.ts` 覆盖 snapshot identity 和旧状态拒绝复用;运行时记录实际端口、进程命令行和 dataDir,不要只记录 HTTP 200。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index a1bdae5e9..25f02695a 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -36,7 +36,7 @@ npm run dev - 主站 Vite。 - 后台 Vite。 -`npm run dev` 和单模块 `npm run dev:web`、`npm run dev:api-server`、`npm run dev:bgfilter-worker`、`npm run dev:spacetime`、`npm run dev:admin-web` 启动后都会更新根目录 `.app/dev-stack.json`。该文件记录本次命令、数据库、更新时间,以及 `spacetime`、`api-server`、`bgfilter-worker`、`web`、`admin-web` 的 `pid`、监听 host / port、可访问 URL、启动状态和当前命令。`.app/` 是本地运行态目录,不提交 Git;端口漂移、服务重启或子进程退出后以该文件里的实际状态为准。 +`npm run dev` 和单模块 `npm run dev:web`、`npm run dev:api-server`、`npm run dev:bgfilter-worker`、`npm run dev:spacetime`、`npm run dev:admin-web` 启动后都会更新根目录 `.app/dev-stack.json`。该文件记录本次命令、数据库、更新时间,以及 `spacetime`、`api-server`、`bgfilter-worker`、`web`、`admin-web` 的 `pid`、监听 host / port、可访问 URL、启动状态和当前命令;稳定版状态还记录顶层 `repoRoot + instanceId`,每个服务记录 `repoRoot + instanceId + dataDir`,与端口组成复用身份。`.app/` 是本地运行态目录,不提交 Git;端口漂移、服务重启或子进程退出后以该文件里的实际状态为准。缺少身份字段或身份不匹配的旧状态不得被 AGC 静默复用。 通过 `nohup` 在仓库根目录启动 dev 栈且未显式重定向 stdout / stderr 时,默认 `nohup.out` 会持续收集 SpacetimeDB、api-server、bgfilter-worker、主站 Vite 和后台 Vite 的整套 dev 栈输出;该文件已被主站 Vite watcher 和 Git 忽略,避免日志追加触发页面刷新循环,重启主站 Vite 后生效。若把输出显式重定向到其它仓库内文件(例如 `> dev.out`),该自定义文件不会自动获得同样的 watcher 保护,应改为写到 Vite root 之外,或同步配置精确的忽略规则。 @@ -62,7 +62,7 @@ Linux 本机多用户并发开发时,`npm run dev`、`npm run dev:*` 单模块 后端日志默认写入 `logs/api-server/`,独立 BgFilter worker 日志默认写入 `logs/bgfilter-worker/`。后端 API smoke 使用 `npm run dev:api-server`,先检查 BgFilter worker `/readyz`,再检查 API `/healthz`;需要确认 API 实例可接生产流量时检查 API `/readyz`。不要使用旧 `api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径。 -AI 游戏创作客户端使用 `npm run agc`。该入口由 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 解析 AGC Vite 实际端口:Linux 默认取当前用户端口段的 `start + 5`,占用时只在本用户段内漂移;Windows / macOS 保留 `3080` 为兼容首选并允许统一漂移。最终端口通过 `GENARRATIVE_AGC_VITE_PORT` 传给 `beforeDevCommand` 和配套后端端口解析器,通过 Tauri CLI 动态 `build.devUrl` 配置传给 WebView,并通过 Vite CLI `--port` 启动严格监听;Vite 继续使用 `strictPort`,任何一层都不得自行改到另一个端口。AGC 配套后端的 `backend` 模式启动 SpacetimeDB、独立 `bgfilter-worker` 和 `api-server`,并在复用现有后端前同时检查三者状态及 `/v1/ping`、`/readyz`、`/healthz`;worker 缺失时不得把不完整的 API/数据库组合误判为 ready。任一配套服务在启动阶段进入 `failed` 时,外层启动器必须立即报告具体服务和退出原因,不能继续等待前端地址超时。端口健康不等于归属正确:复用前还必须证明端口上的监听进程属于当前工作树(Windows 按 `server-rs/target/debug/api-server.exe` 绝对路径与 SpacetimeDB `--data-dir` 校验,探测不可用时退化为旧行为),无法证明归属时一律不复用,改为启动本工作树自己的后端并在需要时端口漂移;否则上个工作树 Ctrl+C 残留的后端会被当成自己的后端复用,改了数据库的工作树会连到旧库。启动器在创建原生窗口前预检最终地址;若竞态中该地址被 AGC Vite、无响应监听器或其它服务占用,一律失败关闭,不复用、也不擅自终止无法证明归属的进程。 +AI 游戏创作客户端使用 `npm run agc`。该入口由 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 解析 AGC Vite 实际端口:Linux 默认取当前用户端口段的 `start + 5`,占用时只在本用户段内漂移;Windows / macOS 保留 `3080` 为兼容首选并允许统一漂移。最终端口通过 `GENARRATIVE_AGC_VITE_PORT` 传给 `beforeDevCommand` 和配套后端端口解析器,通过 Tauri CLI 动态 `build.devUrl` 配置传给 WebView,并通过 Vite CLI `--port` 启动严格监听;Vite 继续使用 `strictPort`,任何一层都不得自行改到另一个端口。AGC 配套后端的 `backend` 模式启动 SpacetimeDB、独立 `bgfilter-worker` 和 `api-server`,并在复用现有后端前同时检查三者状态及 `/v1/ping`、`/readyz`、`/healthz`;worker 缺失时不得把不完整的 API/数据库组合误判为 ready。任一配套服务在启动阶段进入 `failed` 时,外层启动器必须立即报告具体服务和退出原因,不能继续等待前端地址超时。端口健康不等于归属正确:复用前还必须证明端口上的监听进程属于当前工作树(Windows 按 `server-rs/target/debug/api-server.exe` 绝对路径与 SpacetimeDB `--data-dir` 校验,探测不可用时退化为旧行为),无法证明归属时一律不复用,改为启动本工作树自己的后端并在需要时端口漂移;否则上个工作树 Ctrl+C 残留的后端会被当成自己的后端复用,改了数据库的工作树会连到旧库。启动器在创建原生窗口前预检最终地址;AGC Vite marker 同时提供 `repoRoot + processId + port`,与 `.app/dev-stack.json` 的 `instanceId` 和 API target 交叉核对;若竞态中该地址被 AGC Vite、无响应监听器或其它服务占用,一律失败关闭,不复用、也不擅自终止无法证明归属的进程。 Tauri `beforeDevCommand` 默认与客户端构建并行,不能把上述检查只放在 `beforeDevCommand` 内:选定地址上若已有旧 Vite,Tauri 可能先创建加载旧前端的窗口,随后配套后端才因代理不匹配退出。外层启动器会把 Tauri CLI 放入受控进程树;CLI 正常退出、启动失败或收到终止信号后,POSIX 先向保留的 PGID 发送 `SIGTERM`、有界等待后升级 `SIGKILL`,Windows 使用 `taskkill /PID /T /F`。Windows 下每个长驻服务都经 `cmd.exe /d /s /c` 包装层启动,Ctrl+C 会先杀掉包装层(退出码 `0xC000013A`),因此清理不能只看直接子进程是否存活:`taskkill` 对已退出的 PID 只会失败,必须继续按记录下来的根 PID 遍历,并在退出时按本工作树 `api-server.exe` 绝对路径(以及本次自己拉起的 SpacetimeDB `--data-dir`)做一次身份兜底清扫;`scripts/dev-windows-process.mjs` 是这套判定的唯一实现。Linux 容器中的孤儿后代退出后可能暂时保留为 zombie,`kill(-PGID, 0)` 仍会返回成功;启动器必须结合 `/proc//stat` 判断同组是否还存在非 zombie 成员,不能把等待 PID 1 回收误报为清理失败。配套后端和 Vite 仍由 `start-dev-stack.mjs` 各自持有,退出时同样有界收束,避免只剩客户端、Runner、Cargo 或旧订阅进程。排障时同时核对控制台输出的 AGC Vite 实际地址及其 marker、`.app/dev-stack.json` 的实际 API URL 和进程 cwd;不要把“终端已返回”当成客户端及其 Runner 已退出的证据。 diff --git a/docs/【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md b/docs/【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md new file mode 100644 index 000000000..3b4934cc8 --- /dev/null +++ b/docs/【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md @@ -0,0 +1,70 @@ +# 【技术方案】AGC 客户端稳定版生命周期大切换 + +更新时间:`2026-09-14` + +## 目标 + +在 AGC 尚未对外发布的前提下,统一现役客户端入口的异步操作生命周期,并删除会继续制造重复状态机的旧局部防重路径。现有本地项目、`.agent` 文件、Runner 账本和公开后端契约继续作为迁移边界。 + +## 非目标 + +- 不删除或重排本地 manifest、会话、资源生成账本和 Runtime journal。 +- 不删除 `/api/external/v1`、账号、编辑器素材、生成接口、SpacetimeDB schema 或共享 DTO。 +- 不把新玩法、插件、支付、编辑器大功能和主站架构调整并入稳定版切换。 +- 不自动重放无法确认已经受理的 pending 外部操作。 + +## 新的内部合同 + +### ClientOperation + +所有 renderer 异步入口必须能投影以下字段:`operationId`、`requestId`、`kind`、`phase`、`startedAt`、`deadlineAt`、`scope`、`cancelled`。phase 使用 `idle / network / runner / project / success / retryable-failure / unknown`;迟到结果只能在 operationId、scope 和 session generation 都仍匹配时写回。 + +### AuthTransition / RunnerTransition + +启动恢复、手工登录、发送验证码、退出和 401 refresh 共享认证 operation 身份与 session generation。Runner 安装/清除在 blocking worker 执行;前端只展示 network/runner/success/retryable-failure/unknown,并提供重试或重新登录。 + +### HomeCreationOperation + +首页创建由 Launcher controller 持有,payload 保留 draft 快照和 startMode,scope 在建项后绑定 projectPath;创建、附件导入、首轮投递分别推进 phase。页面卸载不取消 operation;成功、可重试失败和不确定状态均可被重新投影。 + +### RecentProjectInspection + +每个最近项目拥有独立 operation 和结果。检查中、可打开、失败、超时、不存在、非目录、未初始化和可导入不能通过一个全局刷新 gate 互相覆盖。 + +### DevStackIdentity + +`.app/dev-stack.json` 和服务 marker 必须包含 `repoRoot + processId + port + dataDir + instanceId` 可验证的身份信息。客户端发现状态时,数据库名、dataDir、repoRoot 或进程归属不匹配就拒绝复用并启动/提示当前工作树自己的服务。 + +## 本地数据迁移 + +- 旧 manifest、项目资源、Planning/GDD、会话和 Runtime journal 继续按现有读取与恢复规则打开。 +- 缺少 operationId 的旧 pending 记录只能转换为可重试或 `unknown`,不自动重放外部副作用。 +- 旧 Runner endpoint 只有在当前 dataDir、repoRoot、进程归属和协议/可用性都匹配时才能复用。 +- 迁移失败保留原文件并给出可操作错误,不以新状态覆盖旧数据。 + +## 任务列表与验收顺序 + +1. **操作合同**:建立共享 `ClientOperation` 类型、状态转移和 stale-result 规则;认证 refresh 与首页创建接入。 +2. **认证/Runner**:统一登录恢复、手工登录、退出、401 refresh 的 operation 投影,保留 session generation 和 blocking worker。 +3. **入口迁移**:首页创建、手动打开、附件导入、Planning V2、DirectProject、资源生成和预览入口复用 operation scope,不再新增 component-level busy ref。 +4. **开发栈身份**:dev-stack snapshot、Vite marker、AGC 配套后端复用门禁统一使用 repoRoot/processId/port/dataDir/instanceId。 +5. **本地恢复**:对旧 pending operation、旧 endpoint 和正在写入的项目执行失败关闭、可重试或人工核对迁移。 +6. **旧路径清理**:仅删除无现役调用方、无持久化合同、无公开契约的旧分支;每次删除前补调用方和持久化证据。 +7. **完整验收**:启动、登录、进入首页、打开项目、对话、确认、资源、预览、切项目、恢复对话/运行态、失败重试、退出登录,并验证坏项目、迟到结果、Runner 重复启动和旧 worktree 串用门禁。 + +## 当前实现状态 + +- HTTP body-aware timeout、refresh singleflight 清理、Runner blocking worker、最近项目逐项检查和首页创建跨页防重已完成并在 PR #346 中提交。 +- `ClientOperation` 基础合同、认证 refresh operation 投影、首页创建 operation 投影和 dev-stack `instanceId` 已在本轮切换中落地。 +- Planning/DirectProject/资源生成/预览已有各自 durable operation 或 request scope;本轮只补统一投影与身份校验,不重写其持久化账本。 + +## 验收证据 + +| 条款 | 证据 | +| --- | --- | +| operation identity 与 stale-result | `clientOperation.test.ts`、认证/home 定向测试 | +| HTTP/auth/Runner | client HTTP/API 测试、Rust cargo check、认证 appSurface | +| 最近项目逐行刷新 | `recentProjectsModel.test.ts`、home appSurface | +| dev-stack 身份 | `scripts/dev.test.ts`、`start-dev-stack.test.ts`、端口 marker 检查 | +| 本地恢复边界 | 现有 manifest/runtime/resource recovery tests;未确认外部副作用不自动重放 | +| 完整流程 | AGC appSurface、开发栈 smoke;真实 Provider/安装包另行记录 | diff --git a/scripts/dev.mjs b/scripts/dev.mjs index 964cba65b..8b13cb61b 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -425,6 +425,7 @@ function buildDevStackSnapshot(runner, updatedAt = new Date().toISOString()) { return { schemaVersion: 2, + instanceId: runner.instanceId, command: runner.command ?? 'all', repoRoot, database: runner.options.database, @@ -450,6 +451,12 @@ function buildDevStackServiceSnapshot(runner, serviceName, updatedAt) { : null; return { + repoRoot, + instanceId: runner.instanceId, + dataDir: + serviceName === 'spacetime' + ? resolve(runner.options.spacetimeDataDir) + : null, status, pid: childPid ?? @@ -1198,6 +1205,9 @@ class DevRunner { constructor(options, baseEnv = process.env, explicitOptions = new Set()) { this.options = options; this.baseEnv = { ...baseEnv }; + this.instanceId = `dev-${process.pid}-${randomBytes(12).toString('hex')}`; + this.baseEnv.GENARRATIVE_DEV_STACK_INSTANCE_ID = this.instanceId; + this.baseEnv.GENARRATIVE_DEV_STACK_REPO_ROOT = repoRoot; this.spacetimeApiToken = String( this.baseEnv.GENARRATIVE_SPACETIME_TOKEN ?? '', ).trim(); diff --git a/scripts/dev.test.ts b/scripts/dev.test.ts index fd932b81b..d7da71610 100644 --- a/scripts/dev.test.ts +++ b/scripts/dev.test.ts @@ -723,6 +723,7 @@ describe('dev scheduler stack state file', () => { test('状态快照记录服务 pid、端口、URL 和当前命令', () => { const updatedAt = '2026-05-29T00:00:00.000Z'; const runner = { + instanceId: 'test-instance', command: 'web', options: { apiHost: '127.0.0.1', @@ -770,12 +771,16 @@ describe('dev scheduler stack state file', () => { const snapshot = buildDevStackSnapshot(runner, updatedAt); expect(snapshot.schemaVersion).toBe(2); + expect(snapshot.instanceId).toBe('test-instance'); expect(snapshot.command).toBe('web'); expect(snapshot.database).toBe('genarrative-test'); expect(snapshot.spacetimeDataDir).toBe( resolve('server-rs/.spacetimedb/local/data'), ); expect(snapshot.services.web).toMatchObject({ + repoRoot: resolve('.'), + instanceId: 'test-instance', + dataDir: null, status: 'running', pid: 4321, host: '0.0.0.0',