退出路径统一收尾本地预览,陈旧预览记录不再留给下次进入
- preview.rs:新增 stop_local_game_preview_on_exit——退出时若 registry 里确有 running 预览,就用 stop_local_game_preview_for_root 把它真正停掉并写 stopped(顺带落 preview.log 与 trace 收尾)。预览服务器是进程内线程,进程一退 URL 就永久失效;不在这里收尾,.agent/manifest.json 会一直写着 running。 - main.rs:handle_game_creator_gui_run_event 的 RunEvent::Exit 分支先做预览收尾,再做既有的 Agent server / runner 关闭;失败按 `preview.gui_exit.stop_failed` 记日志,不阻断退出。 - 语义变更(用户已确认接受):关窗不再保留预览——退出即停止预览并把记录写成 stopped,下次进项目一律落到资源管理。与"进入项目时按 registry 活体核对"配套:退出路径覆盖正常关闭,强杀 / 断电那类不走退出路径的情况由进入时的核对自愈。不引入 stale DTO 字段(判定仍要活体探测,且会把状态扩到跨端契约与全部消费方)。 - tests/project.rs:新增 gui_exit_stops_the_preview_and_clears_the_stale_record——退出收尾后 registry 归零且 manifest 记录为 stopped。 - tests/sessionPreview.test.ts:补两条 Rust 侧结构性守卫(本仓既有做法:直接解析源码)。① RunEvent::Exit 分支必须出现 preview::stop_local_game_preview_on_exit——这条接线没有别的行为测试覆盖(注册全局 registry 的用例会互相打架);② 同项目重启预览的替换分支里,收尾必须排在"旧预览是否属于另一个项目"的判定之后。变异验证:把 main.rs 的退出收尾摘掉 → 守卫①立即红灯(已实测)。 - decision-log.md:新增 2026-09-11 决策条目,逐条写下背景(偶发进运行界面的成因链:进程内预览线程 + 随机临时端口 + 退出零清理 + 进门不核对 + 同项目重启覆盖刚写的 running)、A/B/C 三项决策、关窗不再保留预览这条语义变更及"为什么两者都做而不加 stale 字段"、不做什么与验证方式。同文件被 prettier 补了几处标题前空行(格式规范化,无内容变化)。 - 验证:cargo check --locked --all-targets exit 0;Rust 定向用例 restarting_preview_for_the_same_project / replacing_preview_for_another_project / stopping_without_a_live_preview / gui_exit_stops_the_preview 各 1 passed;AGC 子集 1198 passed / 4 skipped / 0 failed;共享组件 1385 passed;typecheck exit 0;编码 4378 文件;git diff --check 干净。
This commit is contained in:
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`。
|
||||
|
||||
Reference in New Issue
Block a user