diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 7a6f6b7c8..c36311362 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2217,6 +2217,13 @@ where fn handle_game_creator_gui_run_event(event: &tauri::RunEvent) { if matches!(event, tauri::RunEvent::Exit) { + // 退出时统一收尾本地预览:进程内监听线程随进程消失,但 `.agent/manifest.json` + // 里的 preview 记录会留在 running 上,下次进项目就照着它渲染打不开的运行界面。 + if let Err(error) = + preview::stop_local_game_preview_on_exit(&game_creator_preview_registry()) + { + app_log!("preview.gui_exit.stop_failed: {error}"); + } if let Err(error) = agent::shutdown_game_creator_codex_app_servers() { app_log!("agent.direct_codex.gui_exit.shutdown_failed: {error}"); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/preview.rs b/apps/ai-game-creator-shell/src-tauri/src/preview.rs index 4e9d00bda..37f313e9a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/preview.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/preview.rs @@ -819,6 +819,24 @@ pub(crate) fn stop_local_game_preview_for_root( Ok(status) } +/// 退出路径:把当前预览真正停掉,并把落盘记录对齐成 stopped。 +/// +/// 客户端退出是"记录与真相分叉"的主要来源(进程内监听线程随进程消失,记录却留着 +/// running),所以退出时统一收尾。进入项目时的活体核对仍然保留:进程被强杀、断电这类 +/// 不走退出路径的情况,靠那道核对自愈。 +pub(crate) fn stop_local_game_preview_on_exit(registry: &PreviewRegistry) -> Result<(), String> { + let status = registry.status(); + if status.status != "running" { + return Ok(()); + } + let Some(root) = status.root.as_deref() else { + // running 却没有 root 不该出现:只把 listener 停掉,不猜记录该写到哪个项目。 + registry.stop(); + return Ok(()); + }; + stop_local_game_preview_for_root(Some(Path::new(root)), registry).map(|_| ()) +} + #[tauri::command] pub(crate) fn stop_local_game_preview_if_matches( project_path: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index ab4e1025f..a06838883 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -4791,6 +4791,27 @@ fn replacing_preview_for_another_project_closes_the_previous_project_record() { fs::remove_dir_all(second_root).ok(); } +#[test] +fn gui_exit_stops_the_preview_and_clears_the_stale_record() { + let root = unique_project_path(); + init_existing_html_project_at(&root, "project-1", "退出收尾预览记录测试").expect("project init"); + let registry = PreviewRegistry::default(); + let _preview = start_local_game_preview_at(&root, ®istry).expect("preview start"); + + stop_local_game_preview_on_exit(®istry).expect("exit cleanup"); + + assert_eq!(registry.status(), stopped_preview_status()); + let manifest: Value = + serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap()) + .expect("manifest json"); + assert_eq!( + manifest["preview"]["status"], "stopped", + "退出时必须把落盘记录收尾,否则下次进项目会照着 running 渲染打不开的运行界面" + ); + + fs::remove_dir_all(root).ok(); +} + #[test] fn stopping_without_a_live_preview_still_reconciles_a_stale_running_record() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/tests/sessionPreview.test.ts b/apps/ai-game-creator-shell/tests/sessionPreview.test.ts index 5ec837e16..a07793513 100644 --- a/apps/ai-game-creator-shell/tests/sessionPreview.test.ts +++ b/apps/ai-game-creator-shell/tests/sessionPreview.test.ts @@ -1,3 +1,6 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + import { describe, expect, it, vi } from 'vitest'; import type { GameCreationAppPreviewState } from '../../../packages/shared/src/contracts/gameCreationApp'; @@ -156,3 +159,42 @@ describe('进入项目时的预览活体核验', () => { }); }); }); + +describe('本地预览记录的生命周期(Rust 侧结构性守卫)', () => { + const mainSource = readFileSync( + resolve(process.cwd(), 'apps/ai-game-creator-shell/src-tauri/src/main.rs'), + 'utf8', + ); + const previewSource = readFileSync( + resolve( + process.cwd(), + 'apps/ai-game-creator-shell/src-tauri/src/preview.rs', + ), + 'utf8', + ); + + it('客户端退出的 RunEvent::Exit 分支必须接上预览收尾', () => { + // 退出路径是"记录与真相不再分叉"的主要保证:进程内监听线程随进程消失,而落盘记录 + // 会留在 running 上。这条接线没有别的行为测试覆盖(注册全局 registry 的用例会互相 + // 打架),所以按本仓既有做法直接钉源码结构。 + const exitBranch = + /matches!\(event, tauri::RunEvent::Exit\)\s*\{([\s\S]*?)\n {8}\}/u.exec( + mainSource, + )?.[1] ?? ''; + expect(exitBranch).toContain('preview::stop_local_game_preview_on_exit'); + }); + + it('同一个项目重启预览时,旧预览身份不得收尾新写的 running 记录', () => { + // record_replaced_preview_stop 只允许在旧预览属于别的项目时调用:同项目重启时它会把 + // 刚写进去的 running 覆盖成 stopped。 + const replaceBranch = + /let \(preview, previous_preview\) = registry\.set_running\(preview, stop\);([\s\S]*?)\n {4}\}/u.exec( + previewSource, + )?.[1] ?? ''; + expect(replaceBranch).toContain('ensure_preview_belongs_to_project'); + expect(replaceBranch).toContain('record_replaced_preview_stop'); + expect( + replaceBranch.indexOf('ensure_preview_belongs_to_project'), + ).toBeLessThan(replaceBranch.indexOf('record_replaced_preview_stop')); + }); +}); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f9df3aa18..306b6e548 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -151,6 +151,7 @@ - 关联文档:`docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md`、分支 `feat/agc-llm-router-official-chain`。 --- + ## 2026-09-02 GDD 审批卡的后台 hydrate 不抢占已加载决定 - 背景:项目页首次加载和运行态刷新可能并发 hydrate。卡片已经显示后,短暂的 `hydrateBusy` 会让已打开的评论弹层提交按钮瞬时变灰,用户无法提交已输入的修改意见。 @@ -8128,6 +8129,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 文档措辞约束:凡出现“流式响应”“流式事件”或 `text_delta`,均须注明其属于 Provider adapter/Runtime 内部实现能力;不得将其描述为前端必须逐条接收的用户可见消息。V2 的唯一用户交互结果是 `plan_ask_question` 和 `plan_submit_gdd` 的结构化工具结果,Provider 完成前是否产生多个 delta 不参与验收。 ## 2026-08-29 AGC 官方 LLM 代理与 Windows 私有路径修复 + ## 2026-08-29 AGC 官方 LLM 代理与 Windows 私有路径修复 - AGC 正式发行版不再让用户配置 Provider、Base URL 或 API Key。客户端只携带登录 access token 调用 `api-server`;`api-server` 按 access token 的 owner 查询 `llm_router_account`,解密服务端密文后调用固定 `https://router.genarrative.world/v1`。模型目录由后台 owner 管理并持久化到 `agc_model_catalog`,客户端仅显示别名,在对话框右下角选择稳定标识,服务端映射实际模型名;设置页不承载模型选择或方案管理。真实 Router Key 不进入聊天、manifest、trace、日志、项目文件、Codex argv/环境变量或普通 IPC payload。 @@ -8186,6 +8188,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 保留项:V2 与 V1 共用的 GDD 数据模型收敛到 `runtime_protocol/planning_gdd_model.rs`;`.agent/planning/` 旧文件不迁移、不删除,旧 `fast_gdd.md` 仍可只读打开;前端 `PROJECT_SUPERVISOR_PLAN_SOURCE` 字符串与 `GddApprovalCard.tsx`(`PlanGddSurface`)是 V2 现役的适配/展示面,不属于退役对象。 - 旧 sidecar 中带已删字段(`clarification`、`continuation`、`requestId`、`turnId`、`pendingApproval` 等)的记录会因 `deny_unknown_fields` 拒绝反序列化,这是退役语义的一部分,不做迁移。 - 测试基线说明:收尾时测试套件存在 25 个既有失败(mock LLM 时序敏感类,HEAD worktree 对照验证与本次无关),后续清理时不要误记到本次退役头上。 + ## 2026-09-08 Planning V2 正常 run 优先的恢复旁路 - 恢复只利用已经落盘的合法 question、GDD 和 approval receipt;不调用 Provider、不要求模型额外输出恢复字段、不设置复杂状态机或自动重试。 @@ -8269,6 +8272,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 边界:不改 manifest 结构、不改对外 OpenAPI 或 SpacetimeDB schema。该投影同时被 `agc_create_or_derive_resource` 的完成回包复用,两条路径语义一致。 - 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs`、`agent/direct_tools_mcp.rs`。 - 验证:定向 `cargo test -- bridge_registered_resource bridge_art_resource` 3 passed;新增 `bridge_registered_resource_projects_manifest_classification_verbatim` 锁定“显式分类与标签原样透出、不泄漏 prompt/model”;`cargo check --all-targets` 与 AGC 全量套件(1049 passed / 4 skipped / 0 failed)不倒退。 + ## 2026-09-10 AGC 素材重命名命令(文件改名 + localPath 更新,id 不变) - 语义:manifest 资产条目没有 `name` 字段,资源显示名来自 `fileName(localPath)`(`view/project-development/resourceProjectionModel.ts` 的 `label`),所以“重命名”只能是**磁盘文件改名 + manifest `localPath` 更新**,资产 `id` 不变;不新增 manifest / 契约字段。 @@ -8319,3 +8323,12 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 不做什么:不放宽 `.agent/project.lock` 的项目级串行化语义,不引入可重入项目锁,不改“同一调用链禁止二次获取 `.agent/project.lock`”的既有约定,不改 AGC 多进程拓扑,不改 `pendingOperations` 语义,也不改“活持有者始终不回收”的既有判据。其它仍用零等待取锁的入口(`command.exec / project.verify / memory / conversation / task / checkpoint / 预览 / UI 编辑器 / 资源编辑器 / Tauri 命令`)不在本次范围。 - 验证方式:`project_lock_recovery` 追加持锁方身份与权限拒绝两条;`direct_tool_bridge` 追加同进程重叠写等待、同轮并行写、有界等待不占 runtime worker(默认 `current_thread` runtime 加心跳任务,同步阻塞会立刻让心跳停摆)、ACL 拒绝不投影成争用四条;`project/write_lock` 追加“重试性只由错误码决定”与“终态改判三条件”两条平台无关用例(平台作参数传入,Linux CI 覆盖 Windows 分支)。Windows 本机定向结果:`project_write_lock` 18 条、`bridge_write_file` 4 条、`parent_wake` 16 条、`waits_across` 3 条全过;CI(`71e9ad313`)四个 job 全绿,其中 Native shell tests 的首轮失败正是第 ⑤ 条平台判据缺陷。 - 关联文档:`docs/project-memory/shared-memory/pitfalls.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、Issue #318。 + +## 2026-09-11 运行视图的进入只认活体预览,退出时统一收尾落盘预览记录 + +- 背景:用户报"有时候进入项目时看到的是运行界面,而不是资源管理页面"(偶发)。排查定性:**不是"记住上次视图"**(`WorkbenchMode` 无任何持久化),而是**视图派生自业务状态**——工作台有一条 effect「只要有预览 URL 就 `setMode('run')`」,而这个 URL 的初值来自**按项目落盘的 `manifest.preview`**。该记录会与真相长期分叉:本地预览服务器是进程内线程(`preview.rs` 的 `thread::spawn` + loopback listener)、非 Linux 平台端口是随机临时端口(`TcpListener::bind(..., 0)`),客户端退出后记录仍写着 `running`;退出路径当时不 stop 预览、不写 `stopped`(`main.rs` 的 `handle_game_creator_gui_run_event` 只关 Agent server/runner),进入项目时也不核对活体(`get_local_game_preview_status` 只在 `/preview-status` 与「预览状态」按钮里调用)。于是"上一次跑过预览且没显式停止"的项目会被直接丢进一个打不开的运行界面。另有一条反方向失真:同项目重启预览时,`registry.set_running` 返回的旧预览收尾(`record_replaced_preview_stop`)会把刚写进去的 `running` 覆盖成 `stopped`。 +- 决策:① 自动切运行只认**本次会话确认过的预览**(`previewOverride`),落盘记录不再触发切换;运行入口(运行 tab / 播放按钮)与 `runAvailable` 不变,"回到仍在运行的预览"从自动变手动点一下。② 进入项目时按**内存 registry 核对活体**(`resolveSessionPreviewOnProjectOpen`):确实 `running` 才作为会话预览(此时自动进运行视图);记录陈旧则复用现役 `stop_local_game_preview` 把落盘记录对齐成 `stopped`,并把会话 manifest 按真相投影,避免运行入口继续拿打不开的 URL 渲染。③ 旧预览收尾**只对另一个项目**写 `stopped`,同项目重启不再覆盖新写的 `running`。④ **退出路径统一收尾**:`preview::stop_local_game_preview_on_exit` 在 `RunEvent::Exit` 上把当前预览真正停掉并写 `stopped`。 +- 语义变更(用户已确认接受):**关窗不再保留预览**——退出时预览被停止、记录写成 `stopped`,下次进项目一律落到资源管理。之所以两者都做(退出写 `stopped` + 进入时活体核对)而不是只加一个 `stale` DTO 状态:进程被强杀 / 断电不走退出路径,记录仍会分叉,必须有一道进入时的核对自愈;反过来只做核对则每次进项目都要多问一次"上次到底停没停",记录本身也没有在自己的生命周期里结束。选择不做 `stale` 字段:它既不解决"谁来判定 stale"(判定仍要活体探测),又要把状态扩到跨端契约与所有消费方。 +- 不做什么:不改 `runAvailable` 与 `showRunView` 的门槛(PRD「运行入口不可用时仍允许点击…但不切换状态」继续成立),不改 PRD :122 的"播放即切运行视图 + 启动预览",不给预览加心跳 / 轮询。 +- 验证方式:AGC 子集新增两条工作台用例(仅落盘记录为 running → 初始仍是资源管理且运行入口可用;会话预览为 running → 自动进运行视图)与五条 `sessionPreview` 用例(活体优先 / 陈旧记录对齐并投影 / registry 不可读按没有在跑处理 / 记录非 running 时不发多余停止命令 / 无 invoke 零请求);Rust 定向用例三条(同项目重启后记录仍为 running、换项目时旧项目写 stopped、无活体时 stop 对齐陈旧记录)。变异验证:撤掉自动切换的收窄 → 第一条用例红灯;撤掉活体核对(直接信落盘记录)→ `sessionPreview` 两条用例红灯(均已实测)。 +- 关联文档:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`(§4.1 已按当前状态改写)、`docs/project-memory/shared-memory/pitfalls.md`。