Merge branch 'master' into codex/phaser-directproject-migration
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled

This commit is contained in:
2026-09-14 13:57:05 +08:00
21 changed files with 591 additions and 53 deletions
@@ -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,
};
}
@@ -860,14 +860,11 @@ pub(crate) fn acquire_project_write_lock_failure(
}
}
}
if crate::agent::autonomous_game_build_root_run_active_at(root)
&& project_write_lock_is_owned_by_current_process(&path)
{
// The autonomous game-build lane intentionally permits
// parallel specialist actions. If the durable lock belongs
// to this very process, contention is an in-process overlap,
// not another application editing the project. Return an
// advisory guard and leave the real lock untouched.
if project_write_lock_is_owned_by_current_process(&path) {
// A project lock is the client-use lock. Nested calls in
// the same client process must reuse that ownership instead
// of waiting on their own durable marker. Cross-process
// contenders still take the normal retryable path.
return Ok(ProjectWriteLock {
path,
content: String::new(),
@@ -5611,12 +5611,14 @@ fn local_project_checkpoint_diff_restore_and_index_are_recorded() {
}
#[test]
fn project_write_lock_rejects_parallel_writer_and_releases_on_drop() {
fn project_write_lock_reuses_same_process_owner_and_releases_on_drop() {
let root = unique_project_path();
let first = acquire_project_write_lock(&root, "file.write").expect("first lock");
let error = acquire_project_write_lock(&root, "file.delete").expect_err("second lock fails");
assert!(error.contains("项目正在被其他写操作占用"));
let nested = acquire_project_write_lock(&root, "file.delete")
.expect("same process must reuse the client project lock");
drop(nested);
assert!(root.join(PROJECT_WRITE_LOCK_PATH).exists());
drop(first);
acquire_project_write_lock(&root, "file.delete").expect("lock released");
@@ -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);
@@ -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<ClientOperation<
'home-create',
{
draft: HomeDraft;
startMode: ProjectStartMode;
}
> | 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,
@@ -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<string, Promise<string>>();
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<AuthRefreshResponse>(
'/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);
@@ -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<TKind extends string, TPayload>(
kind: TKind,
payload: TPayload,
options: {
scope?: ClientOperationScope;
deadlineMs?: number | null;
cancellable?: boolean;
} = {},
): ClientOperation<TKind, TPayload> {
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<TKind extends string, TPayload>(
operation: ClientOperation<TKind, TPayload>,
phase: ClientOperationPhase,
patch: Partial<
Pick<ClientOperation<TKind, TPayload>, 'scope' | 'payload'>
> = {},
) {
return {
...operation,
...patch,
scope: patch.scope
? { ...operation.scope, ...patch.scope }
: operation.scope,
phase,
};
}
export function cancelClientOperation<TKind extends string, TPayload>(
operation: ClientOperation<TKind, TPayload>,
) {
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';
}
@@ -6,6 +6,11 @@ import {
refreshClientAuthAccessToken,
} from './clientAuth';
import { getClientServerBaseUrl } from './clientHttp';
import {
type ClientOperation,
createClientOperation,
transitionClientOperation,
} from './clientOperation';
const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1';
@@ -34,12 +39,20 @@ let committedPlatformSession: CommittedPlatformSession | null = null;
let desiredPlatformSession: CommittedPlatformSession | null = null;
let platformSessionRefreshPromise: Promise<PlatformSessionRefreshResult> | null =
null;
let platformSessionOperation: ClientOperation<
'auth-transition',
{ userId: string | null }
> | null = null;
let platformSessionNativeMutationTail: Promise<void> = Promise.resolve();
const platformSessionRefreshListeners =
new Set<PlatformSessionRefreshListener>();
const platformSessionGenerationListeners =
new Set<PlatformSessionGenerationListener>();
export function getPlatformSessionOperation() {
return platformSessionOperation;
}
function restoreCommittedAccessToken() {
if (committedPlatformSession?.accessToken) {
window.localStorage.setItem(
@@ -259,15 +272,44 @@ export async function commitAuthenticatedPlatformSession(
if (!accessToken) {
throw new Error('陶泥儿登录凭据缺失,请重新登录');
}
const operation = createClientOperation(
'auth-transition',
{ userId: user.id },
{
scope: { apiBaseUrl, sessionGeneration: expectedGeneration },
deadlineMs: 45_000,
cancellable: false,
},
);
platformSessionOperation = transitionClientOperation(operation, 'runner');
return enqueuePlatformSessionNativeMutation(async () => {
const session = await commitPlatformSession(
user,
accessToken,
apiBaseUrl,
expectedGeneration,
);
if (!session) return null;
return session.generation;
try {
const session = await commitPlatformSession(
user,
accessToken,
apiBaseUrl,
expectedGeneration,
);
if (!session) {
platformSessionOperation = transitionClientOperation(
operation,
'unknown',
);
return null;
}
platformSessionOperation = transitionClientOperation(
operation,
'success',
{ scope: { sessionGeneration: session.generation } },
);
return session.generation;
} catch (error) {
platformSessionOperation = transitionClientOperation(
operation,
'retryable-failure',
);
throw error;
}
});
}
@@ -375,30 +417,64 @@ export function subscribePlatformSessionGeneration(
}
export async function clearCommittedPlatformSession(generation: number) {
const operation = createClientOperation(
'auth-transition',
{ userId: null },
{
scope: { sessionGeneration: generation },
deadlineMs: 45_000,
cancellable: false,
},
);
platformSessionOperation = transitionClientOperation(operation, 'runner');
return enqueuePlatformSessionNativeMutation(async () => {
if (platformAuthGeneration !== generation) {
await reconcileNativePlatformSessionToCurrentAuthority();
return;
}
desiredPlatformSession = null;
const nativeGeneration = await reserveNativePlatformSessionGeneration();
try {
await clearNativePlatformSession(nativeGeneration);
} catch {
if (platformAuthGeneration === generation) {
desiredPlatformSession = null;
if (platformAuthGeneration !== generation) {
await reconcileNativePlatformSessionToCurrentAuthority();
platformSessionOperation = transitionClientOperation(
operation,
'unknown',
);
return;
}
await reconcileNativePlatformSessionToCurrentAuthority();
return;
desiredPlatformSession = null;
const nativeGeneration = await reserveNativePlatformSessionGeneration();
try {
await clearNativePlatformSession(nativeGeneration);
} catch {
if (platformAuthGeneration === generation) {
desiredPlatformSession = null;
}
await reconcileNativePlatformSessionToCurrentAuthority();
platformSessionOperation = transitionClientOperation(
operation,
'retryable-failure',
);
return;
}
if (platformAuthGeneration !== generation) {
await reconcileNativePlatformSessionToCurrentAuthority();
platformSessionOperation = transitionClientOperation(
operation,
'unknown',
);
return;
}
committedPlatformSession = null;
desiredPlatformSession = null;
restoreCommittedAccessToken();
notifyPlatformSessionGeneration();
platformSessionOperation = transitionClientOperation(
operation,
'success',
);
} catch (error) {
platformSessionOperation = transitionClientOperation(
operation,
'retryable-failure',
);
throw error;
}
if (platformAuthGeneration !== generation) {
await reconcileNativePlatformSessionToCurrentAuthority();
return;
}
committedPlatformSession = null;
desiredPlatformSession = null;
restoreCommittedAccessToken();
notifyPlatformSessionGeneration();
});
}
@@ -409,6 +485,7 @@ export function resetPlatformSessionStateForTests() {
committedPlatformSession = null;
desiredPlatformSession = null;
platformSessionRefreshPromise = null;
platformSessionOperation = null;
platformSessionNativeMutationTail = Promise.resolve();
platformSessionRefreshListeners.clear();
platformSessionGenerationListeners.clear();
@@ -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();
@@ -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);
});
});
@@ -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);
@@ -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,
}),