Compare commits

..

9 Commits

Author SHA1 Message Date
lhk229 a8bb2bee95 Merge branch 'master' into opt/win-ssache
Project CI / Repository checks (pull_request) Waiting to run
Project CI / Frontend tests (pull_request) Waiting to run
Project CI / Backend tests (pull_request) Waiting to run
Project CI / Native shell tests (pull_request) Waiting to run
2026-09-14 15:19:12 +08:00
kdletters 7092689b36 收口 AGC 生命周期超时隔离与建项恢复
Project CI / Repository checks (push) Successful in 3m11s
Project CI / Native shell tests (push) Has started running
Project CI / Frontend tests (push) Successful in 4m4s
Project CI / Backend tests (push) Successful in 6m14s
## 变更内容

在 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>
2026-09-14 14:49:45 +08:00
lhk229 37743f66c4 修正 POSIX sccache 绕过日志
Project CI / Repository checks (pull_request) Failing after 2m42s
Project CI / Frontend tests (pull_request) Failing after 2m29s
Project CI / Native shell tests (pull_request) Failing after 6m33s
Project CI / Backend tests (pull_request) Successful in 7m36s
明确记录 Linux 和 macOS 本地开发实际绕过 sccache

补充 POSIX 日志回归测试并更新排障文档
2026-09-14 06:29:28 +00:00
lhk229 b84cb2b07b Merge remote-tracking branch 'origin/master' into opt/win-ssache
Project CI / Repository checks (pull_request) Failing after 3m51s
Project CI / Frontend tests (pull_request) Failing after 3m47s
Project CI / Native shell tests (pull_request) Failing after 5m48s
Project CI / Backend tests (pull_request) Successful in 6m29s
2026-09-14 06:21:45 +00:00
lhk229 136756134a 按显式配置控制 Windows sccache
未配置 wrapper 时默认回退到直接 rustc

处理 sccache 探测失败与 wrapper 配置冲突

同步更新开发运维和排障文档
2026-09-14 06:19:38 +00:00
lhk229 e85c216112 增强 Windows sccache wrapper 探测回退
Project CI / Native shell tests (pull_request) Successful in 23m27s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / Repository checks (pull_request) Failing after 13s
Project CI / Frontend tests (pull_request) Successful in 3m19s
使用限时真实 rustc wrapper 探测替代版本检查

探测失败或超时时回退到直接 rustc

同步更新 sccache 故障排障记录
2026-09-14 06:02:23 +00:00
lhk229 2315883b1f 修正 sccache 测试的平台隔离
Project CI / Repository checks (pull_request) Failing after 13s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / Frontend tests (pull_request) Successful in 3m38s
Project CI / Native shell tests (pull_request) Successful in 20m22s
为启用 sccache 的用例补充 Windows platform mock

确保 POSIX 环境下测试不会误走 env wrapper 分支
2026-09-14 05:56:28 +00:00
lhk229 4bb0704d3e Merge branch 'master' into opt/win-ssache
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
2026-09-14 13:23:56 +08:00
lhk229 cea3e2340f 支持 Windows 本地 sccache 自动回退
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
Windows 本地开发检测可用的 sccache 并启用缓存

未安装或不可执行时清空 Rust wrapper 并回退到 rustc

补充测试并更新开发运维文档
2026-09-14 05:20:11 +00:00
30 changed files with 6280 additions and 176 deletions
@@ -957,6 +957,9 @@ async function runInteractiveCargo(cliArguments, setActiveChild) {
return result;
}
// 立项策划跑 standard 档,`agent.delegate` 这类动作按项目权限策略必须逐个确认,
// 而确认和问询都只从 CLI 的 stdin 读。自主构建档没有这一步,所以只有 --plan 需要
// 一个把「人坐在终端前敲 approve」自动化掉的应答器;判据本身仍然走后端确认命令。
const swarmConfirmationPromptPattern = /输入 approve 或 reject$/u;
const swarmUserInputPromptPattern = /请选择 1-\d+,或直接输入其他答案:$/u;
@@ -795,10 +795,15 @@ pub(crate) async fn continue_design_agent_at(
.ok_or("策划 Agent 当前正在工作")?;
let mut session = match read_design_session(root)? {
Some(session) => session,
None => new_design_session(
&project_id,
&load_game_creator_app_config()?.selected_model_id,
),
None => {
if read_planning_session_v2(root)?.is_some() {
return Err("此项目包含旧策划会话,请查看原有记录或在新项目开始五阶段策划".into());
}
new_design_session(
&project_id,
&load_game_creator_app_config()?.selected_model_id,
)
}
};
if session.project_id != project_id {
return Err("策划会话与当前项目不匹配".into());
@@ -8,6 +8,8 @@ mod finalization;
mod json_sidecar;
mod models;
mod planning_gdd_model;
mod planning_policy_v2;
mod planning_session_v2;
mod provider_control;
mod provider_retry;
mod real_e2e_checkpoint;
@@ -23,6 +25,8 @@ pub(in crate::agent) use finalization::*;
pub(in crate::agent) use json_sidecar::*;
pub(in crate::agent) use models::*;
pub(crate) use planning_gdd_model::*;
pub(crate) use planning_policy_v2::*;
pub(crate) use planning_session_v2::*;
pub(in crate::agent) use provider_control::*;
pub(in crate::agent) use provider_retry::*;
pub(in crate::agent) use real_e2e_checkpoint::*;
@@ -2628,6 +2628,10 @@ fn main() {
chat_with_game_creator_role_agent,
chat_with_game_creator_role_agent_stream,
chat_with_game_creator_direct_codex,
start_planning_session_v2,
continue_planning_session_v2,
decide_planning_artifact_v2,
hydrate_planning_session_v2,
hydrate_design_agent_session,
reset_design_agent_session,
get_design_agent_runtime_mode,
@@ -372,7 +372,7 @@ pub(crate) fn project_write_lock_reclaim(
}
/// `.agent/project.lock` 的争用错误前缀。`project_gates.rs`、`provider_recovery.rs`、
/// `direct_runtime.rs` 和前端 `App.tsx` 都按这个前缀把争用
/// `planning_session_v2.rs`、`direct_runtime.rs` 和前端 `App.tsx` 都按这个前缀把争用
/// 识别成"可以等一下"的瞬时状态;文案扩展时要保持前缀逐字不变。
pub(crate) const PROJECT_WRITE_LOCK_CONTENTION_PREFIX: &str = "项目正在被其他写操作占用:";
@@ -476,7 +476,7 @@ impl ProjectWriteLockFailure {
}
/// 零等待入口的文案。可重试的失败保持争用前缀逐字不变:`provider_recovery.rs`、
/// `direct_runtime.rs` 和前端 `App.tsx` 都按这个前缀把错误
/// `planning_session_v2.rs`、`direct_runtime.rs` 和前端 `App.tsx` 都按这个前缀把错误
/// 当成可等待的瞬时状态,改前缀等于顺手改掉它们的重试语义。
pub(crate) fn message(&self) -> String {
match self {
+34
View File
@@ -229,6 +229,7 @@ import {
parseRememberInput,
} from './features/project-workspace/memoryCommands';
import { pendingCommandDetail } from './features/project-workspace/pendingCommandPresentation';
import { planningStateNeedsRuntimeRefresh } from './features/project-workspace/planningLane';
import {
type PlanningApprovalCommandResultV2,
planningMessagesToChatMessages,
@@ -1201,6 +1202,39 @@ export function App({
void hydratePlanGddState(targetProjectPath);
}, [hydratePlanGddState, localProject?.projectPath]);
useEffect(() => {
// 存在性判据故意走 `status`(必选字段,为 `undefined` 当且仅当 runtime 为 null)而不是整个
// 对象:依赖里只挖 phase/status/updatedAt 三个标量,是为了只在监工状态真的动了时
// 重灌。把 `projectSupervisorRuntime` 本体写进依赖会让每一轮轮询新建的对象身份都触发一次
// hydrate,白烧 IPC。
if (
!localProject?.projectPath ||
projectSupervisorRuntime?.status === undefined
) {
return;
}
// 后端 hydrate 会抢项目写锁并扫 authority,不是纯内存读。没有这道门,做游戏和做
// 素材链路的每一拍监工心跳都会去抢一次项目写锁——而那两条链路根本不产生策划状态。
// 策划状态读 ref 而不进依赖:hydrate 成功就会换一个 `planGddState` 对象身份,写进
// 依赖等于 hydrate 触发 hydrate。
if (
!planningStateNeedsRuntimeRefresh(
projectSupervisorRuntime?.source,
planGddStateRef.current,
)
) {
return;
}
void hydratePlanGddState(localProject.projectPath);
}, [
hydratePlanGddState,
localProject?.projectPath,
projectSupervisorRuntime?.phase,
projectSupervisorRuntime?.source,
projectSupervisorRuntime?.status,
projectSupervisorRuntime?.updatedAt,
]);
useEffect(() => {
const hydrateOnResume = () => {
if (document.visibilityState === 'hidden' || !localProject?.projectPath) {
@@ -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;
}
@@ -676,6 +676,7 @@ export function ProjectSupervisorRuntimePanel({
error: string;
runtimeByAgentId: Record<string, AgentRuntimeState | undefined>;
controlBusy: boolean;
planGddAwaitingDecision?: boolean;
readOnly?: boolean;
professionalResultsByAgentId: Record<
string,
@@ -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,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,171 @@
import { useEffect, useState } from 'react';
import type {
AgentRuntimeState,
AgentRuntimeUserInputRequest,
} from '../../app/types';
import {
AgentRuntimeUserInputCard,
projectRuntimeVisibleError,
} from '../agent-runtime';
type PlanningLaneRuntimeStripProps = {
runtime: AgentRuntimeState | null;
error: string;
controlBusy: boolean;
readOnly?: boolean;
onSupervisorRetry: (runtime: AgentRuntimeState) => Promise<string>;
onUserInput: (
request: AgentRuntimeUserInputRequest,
responseId: string,
answers: Record<string, string>,
) => void | Promise<void>;
};
/**
* 立项策划链路下替代 `ProjectSupervisorRuntimePanel` 的窄条。
*
* 完整面板是为做游戏链路设计的:十几个专业 Agent、多步计划、逐 Agent 重试。套到
* 策划链路上,子 Agent 永远只有 `project-planning` 一个,计划永远一两步,「专业
* Agent 协作:1」永远是 1——它把 D11 的「总控 + 委派子 Run」拓扑整个漏给了用户,而
* 用户的心智模型是在跟一个策划聊天。状态本身由顶部的 `PlanGddStageProgress` 承担。
*
* 这里只画真正需要用户动手的两样:澄清问答卡,以及失败后的恢复入口。其余时候
* 返回 null,不占一行。
*
* 完整面板在 `waiting-for-user-input` 却读不到 `userInputRequest` 时会画一句
* 「待回答问题未能读取」。策划链路里这个组合出现在子 Run 退出到父 Run 醒来之间的
* 瞬时窗口,以及审批等待(交互面是审批卡)——两种都不是读取失败,所以这里不画。
*/
export function PlanningLaneRuntimeStrip({
runtime,
error,
controlBusy,
readOnly = false,
onSupervisorRetry,
onUserInput,
}: PlanningLaneRuntimeStripProps) {
const [retrySubmitting, setRetrySubmitting] = useState(false);
const [retryAccepted, setRetryAccepted] = useState(false);
const [retryFeedback, setRetryFeedback] = useState('');
useEffect(() => {
setRetrySubmitting(false);
setRetryAccepted(false);
if (runtime?.status === 'failed' || runtime?.phase === 'failed') {
setRetryFeedback('');
}
}, [runtime?.phase, runtime?.runId, runtime?.status]);
const userInputRequest = readOnly
? null
: (runtime?.userInputRequest ?? null);
const needsReconciliation = Boolean(
runtime &&
(runtime.status === 'needs-reconciliation' ||
runtime.phase === 'needs-reconciliation'),
);
// Planning V2 has no supported manual retry path. Its Provider failure is
// terminal for the current session; exposing the generic Supervisor retry
// would incorrectly enter the retired V1 Runtime and report a busy service.
const showRecovery = false;
// 与完整面板同源:App 层的操作错误(如「请先回答当前的澄清问题」)优先,其次是
// run 自己记下的失败原因。这是原面板里唯一真正面向用户的一行文字,照搬。
const rawErrorDetail = error || runtime?.error || '';
const errorDetail = rawErrorDetail
? projectRuntimeVisibleError(rawErrorDetail, '项目总控 Agent', true)
: '';
if (!userInputRequest && !showRecovery && !errorDetail) {
return null;
}
return (
<section
className="agent-runtime-status planning-lane-runtime-strip"
aria-label="立项策划运行状态"
>
{errorDetail ? (
<small className="project-runtime-error" role="alert">
{errorDetail}
</small>
) : null}
{showRecovery && runtime ? (
<div
className="project-runtime-recovery"
aria-label={
needsReconciliation ? '立项策划待核对恢复' : '立项策划失败恢复'
}
>
<span>
{needsReconciliation ? (
<>
<small></small>
</>
) : (
<>
<small></small>
</>
)}
</span>
<button
type="button"
disabled={controlBusy || retrySubmitting || retryAccepted}
onClick={() => {
setRetryFeedback(
needsReconciliation
? '正在结束待核对的旧任务…'
: '正在重新启动策划…',
);
setRetrySubmitting(true);
void onSupervisorRetry(runtime)
.then((message) => {
setRetryAccepted(true);
setRetryFeedback(message);
})
.catch((retryError) => {
setRetryAccepted(false);
setRetryFeedback(
projectRuntimeVisibleError(
retryError instanceof Error
? retryError.message
: String(retryError),
'项目总控 Agent',
true,
),
);
})
.finally(() => setRetrySubmitting(false));
}}
>
{retrySubmitting
? needsReconciliation
? '正在结束旧任务…'
: '正在重新启动…'
: retryAccepted
? needsReconciliation
? '旧任务结束请求已受理'
: '重试已受理'
: needsReconciliation
? '已核对,结束旧任务'
: '重新启动策划'}
</button>
{retryFeedback ? (
<small className="project-runtime-retry-feedback" role="status">
{retryFeedback}
</small>
) : null}
</div>
) : null}
{userInputRequest && !controlBusy ? (
<AgentRuntimeUserInputCard
key={`${userInputRequest.requestId}:${userInputRequest.responseId ?? 'pending'}`}
request={userInputRequest}
controlBusy={controlBusy}
onSubmit={onUserInput}
/>
) : null}
</section>
);
}
@@ -13,6 +13,8 @@ import type {
GameCreatorDirectTurnUpdateStatus,
PendingCommand,
PendingUiConfirmation,
PlanGddDecisionAction,
PlanGddStateViewV1,
} from '../../app/types';
import type { DesignClarificationRequest, DesignView } from '../../app/types';
import { ChatMarkdownMessage } from '../../components/ChatMarkdownMessage';
@@ -33,10 +35,13 @@ import {
DesignAgentPendingActions,
DesignAgentPhaseStatus,
} from './DesignAgentSurface';
import { PlanGddSurface } from './GddApprovalCard';
import {
pendingCommandDetail,
pendingCommandTitle,
} from './pendingCommandPresentation';
import { isPlanningLaneRuntime } from './planningLane';
import { PlanningLaneRuntimeStrip } from './PlanningLaneRuntimeStrip';
import { resolvePendingCommandProjectPath } from './projectCommandPolicy';
import {
ResourceReferenceInput,
@@ -95,6 +100,17 @@ type ProjectSupervisorViewProps = RuntimePanelProps & {
visibleMessages: ChatMessage[];
visibleProfessionalAgentCards: AgentStatusCard[];
workspaceStatus: string;
planGddState: PlanGddStateViewV1 | null;
planGddHydrateBusy: boolean;
planGddDecisionBusy: boolean;
planGddError: string | null;
planningLane?: boolean;
onPlanGddRefresh: () => void;
onPlanGddDecision: (
action: PlanGddDecisionAction,
comment: string | null,
) => Promise<void>;
onMakeGameFromApprovedGdd?: () => Promise<void>;
versions?: import('../../../../../packages/shared/src/contracts/gameCreationApp').GameIterationVersion[];
designView?: DesignView | null;
onDesignApprove?: (requestId: string, approved: boolean) => void;
@@ -136,6 +152,14 @@ export function ProjectSupervisorView({
visibleMessages,
visibleProfessionalAgentCards,
workspaceStatus,
planGddState,
planGddHydrateBusy,
planGddDecisionBusy,
planGddError,
planningLane = false,
onPlanGddRefresh,
onPlanGddDecision,
onMakeGameFromApprovedGdd,
versions,
designView = null,
onDesignApprove,
@@ -143,6 +167,8 @@ export function ProjectSupervisorView({
onDesignRetry,
...runtimePanelProps
}: ProjectSupervisorViewProps) {
const planningSurfaceActive =
planningLane || isPlanningLaneRuntime(runtimePanelProps.runtime);
const [expandedProcessKey, setExpandedProcessKey] = useState<string | null>(
null,
);
@@ -191,13 +217,25 @@ export function ProjectSupervisorView({
{designView || onDesignApprove ? (
<DesignAgentPhaseStatus
view={designView}
busy={runtimePanelProps.controlBusy}
error={runtimePanelProps.error}
busy={runtimePanelProps.controlBusy || planGddDecisionBusy}
error={planGddError}
onApprove={onDesignApprove ?? (() => undefined)}
onClarify={onDesignClarify ?? (() => undefined)}
onRetry={onDesignRetry ?? (() => undefined)}
/>
) : null}
) : (
<PlanGddSurface
state={planGddState}
active={planningSurfaceActive}
projectPath={projectPath}
hydrateBusy={planGddHydrateBusy}
decisionBusy={planGddDecisionBusy}
error={planGddError}
onRefresh={onPlanGddRefresh}
onDecision={onPlanGddDecision}
onMakeGame={onMakeGameFromApprovedGdd}
/>
)}
<div
ref={messagesRef}
className="message-list project-supervisor-message-list"
@@ -305,8 +343,20 @@ export function ProjectSupervisorView({
</div>
) : null}
</div>
{directCodex ? null : (
<ProjectSupervisorRuntimePanel {...runtimePanelProps} />
{directCodex ? null : planningSurfaceActive ? (
<PlanningLaneRuntimeStrip
runtime={runtimePanelProps.runtime}
error={runtimePanelProps.error}
controlBusy={runtimePanelProps.controlBusy}
readOnly={runtimePanelProps.readOnly}
onSupervisorRetry={runtimePanelProps.onSupervisorRetry}
onUserInput={runtimePanelProps.onUserInput}
/>
) : (
<ProjectSupervisorRuntimePanel
{...runtimePanelProps}
planGddAwaitingDecision={Boolean(planGddState?.pendingApproval)}
/>
)}
{pendingCommand ? (
<div className="pending-command">
@@ -349,8 +399,8 @@ export function ProjectSupervisorView({
{designView || onDesignApprove ? (
<DesignAgentPendingActions
view={designView}
busy={runtimePanelProps.controlBusy}
error={runtimePanelProps.error}
busy={runtimePanelProps.controlBusy || planGddDecisionBusy}
error={planGddError}
onApprove={onDesignApprove ?? (() => undefined)}
onClarify={onDesignClarify ?? (() => undefined)}
onRetry={onDesignRetry ?? (() => undefined)}
@@ -0,0 +1,61 @@
import { PROJECT_SUPERVISOR_PLAN_SOURCE } from '../../app/constants';
import type { AgentRuntimeState, PlanGddStateViewV1 } from '../../app/types';
/**
* 当前总控 run 是否属于立项策划链路。
*
* 判据是 run 的 `source`,不是 `planGddState`:做游戏链路在策划批准之后照样带着
* 一份 approved 的策划状态,但它的总控 run 是 autonomous 源,必须继续拿完整面板。
*/
export function isPlanningLaneRuntime(
runtime: AgentRuntimeState | null | undefined,
) {
return isPlanningLaneSource(runtime?.source);
}
/**
* 同一个判据的标量入口。
*
* `App.tsx` 里那条按监工状态重灌策划状态的 effect,依赖里只放 phase/status/updatedAt
* 这类标量——轮询每拍都会新建 runtime 对象,把本体写进依赖会让每一拍都重跑。要在那
* 条 effect 里用上链路判据,就只能拿 `source` 这一个标量进去。
*/
export function isPlanningLaneSource(source: string | null | undefined) {
return source === PROJECT_SUPERVISOR_PLAN_SOURCE;
}
/** 策划状态里还会继续变的那几个态。`approved` / `rejected` 是终态。 */
const PLAN_GDD_LIVE_STATES: ReadonlySet<PlanGddStateViewV1['state']> = new Set([
'draft',
'ready_for_approval',
'revision_requested',
]);
/**
* 监工状态每次变动时,要不要重新 hydrate 策划状态。
*
* hydrate 不是纯内存读:后端会抢项目写锁、扫 authority、必要时修投影。把它挂在
* 「任意 run 的任意一次更新」上,等于让做游戏和做素材链路的每一拍心跳都去抢一次
* 项目写锁。
*
* 但也不能简单地只看 `isPlanningLaneRuntime`:审批卡的可见性判据是
* `displayGdd && (pendingApproval || recoveryPending)`,跟当前 run 的 source 无关,
* 而非策划分支的监工面板还要靠 `planGddState.pendingApproval` 点亮等待审批位。所以
* 策划状态自身还没落定时,即便当前 run 不是策划链路也必须继续跟。
*/
export function planningStateNeedsRuntimeRefresh(
runtimeSource: string | null | undefined,
planGddState: PlanGddStateViewV1 | null | undefined,
) {
if (isPlanningLaneSource(runtimeSource)) {
return true;
}
if (!planGddState) {
return false;
}
return Boolean(
planGddState.pendingApproval ||
planGddState.recoveryPending ||
PLAN_GDD_LIVE_STATES.has(planGddState.state),
);
}
@@ -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
@@ -14,6 +14,7 @@ import {
registerHomeProjectCreationTests,
registerRecentProjectsTests,
} from './appSurface/home.suite';
import { registerPlanGddApprovalTests } from './appSurface/plan-gdd.suite';
import {
registerCanvasAssetTests,
registerProjectAssetTests,
@@ -71,5 +72,6 @@ describe('AI 游戏创作 App 界面边界', () => {
registerProjectAssetTests();
registerAgentRuntimeCommandTests();
registerCanvasAssetTests();
registerPlanGddApprovalTests();
registerDesignAgentSurfaceTests();
});
@@ -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],
File diff suppressed because it is too large Load Diff
@@ -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,21 +0,0 @@
# 关联里程碑
`【里程碑】退役策划V2 Rust Runtime清理-2026-09-14.md`
# 修改顺序
1.`runtime_protocol.rs` 移除 V2 模块声明与导出。
2.`main.rs` / `commands.rs` 移除 V2 command 注册和仅供 V2 的导入。
3. 删除 V2 Rust 模块及其专属单元测试;保留共享 GDD 模型或新版设计会话仍使用的类型。
4.`rg` 检查 V2 Rust 符号残留,修复编译引用。
# 验证命令
- `cargo check --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml`
- `npm run check:encoding`
- `git diff --check`
# 风险与回滚
- 风险:V2 类型可能被共享测试或前端桥接代码引用。处理方式是按编译错误逐项判断,保留真正共享类型。
- 回滚:按提交粒度回退本里程碑提交,不触碰前序 V1 清理提交。
@@ -1,38 +0,0 @@
# Version
V2-RUST-RETIRE-1
# Status
in-progress
# Date
2026-09-14
# Parent Spec
`docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md`
# 目标
删除已经被独立 Design Agent 取代的旧策划 V2 Rust Runtime、Tauri 命令注册和仅服务 V2 的模块导出,使桌面壳继续编译并保留做游戏 Agent 与新版 Design Agent。
# 边界
- 删除 `planning_policy_v2``planning_session_v2` 及仅供这两者使用的 V2 注册和调用。
- 删除 V2 专属的 Tauri command 注册、模块导出和测试入口。
- 保留 `design_runtime``design_tools``design_session`、通用 runtime、DirectProject 和做游戏 Agent。
- 本里程碑不处理前端 V2 数据层、UI、文档索引和共享运行时中的可选清理。
# 验收标准
1. Rust 源码不再编译 `planning_policy_v2.rs``planning_session_v2.rs`
2. `main.rs``commands.rs` 和 runtime protocol 不再注册或导出 V2 命令。
3. 新版 Design Agent 与做游戏 Agent 的 Rust 编译路径保持可用。
4. 相关定向 Rust 测试和 `cargo check` 通过。
# 依赖
- 当前分支已包含 PR159 的 V1 清理。
- 前端 V2 调用暂时保留,待后续里程碑同步删除。

Some files were not shown because too many files have changed in this diff Show More