From 67d06bc16853a7b18b609afd0236939c40559a29 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 5 Oct 2026 00:55:29 +0800 Subject: [PATCH] =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E8=AF=84=E5=AE=A1=E6=94=B6?= =?UTF-8?q?=E5=8F=A3=20#599=EF=BC=9A=E5=91=BD=E5=90=8D=E8=A1=8C=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E6=97=B6=E5=BA=8F=E3=80=81=E5=91=BD=E5=90=8D=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=20update=20=E9=97=A8=E7=A6=81=E3=80=81=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E5=85=B3=E9=97=AD=E4=B8=8E=20legacy=20=E7=94=A8?= =?UTF-8?q?=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P2-2:命名行的账本订阅与恢复读合并为同一次 effect 的「先订阅、后读、只读一次」(与素材队列先订阅后派发同口径),并在「生成任务」面板打开且**确有在途命名行**时补读一次兜底——多读会改变既有生成任务链路可观测的轮询计数,因此不给没有命名任务的项目加读。新增 2 条 appSurface 用例(错过终态事件仍到终态 / 面板打开补读),并做了「改前红」验证:退回「先读后订阅 + 面板不补读」两条即红。 - P2-3:update_local_project_naming_task 补 project.rename 权限门禁(与 enqueue 同口径)、校验记录 project_id 与项目一致、终态只接受同状态幂等重放(completed→failed 一律 Err 且不写盘);新增 3 条 Rust 用例。 - P2-4:新增失败关闭与 schema 分支用例——超长项目名条件改名 Err 且 manifest/revision 不变、不支持 schemaVersion Err、缺 schemaVersion 按 v1 接受。 - P3-1:legacy v1 兼容用例改为断言兼容性质(字段原样保留),不再引用 phase 文案常量。 - 文档:pitfalls 补「兼容是单向的(旧构建读含命名记录的账本会整份失败,含影响面/触发条件/潜在低成本正向兼容方向)」与「先订阅后读快照」「update 门禁与终态语义」三条边界;decision-log 同步边界与数字。 - 门禁:AGC 全量 1964 passed / 17 skipped(appSurface 200 passed / 9 skipped);cargo asset_generation_task 27 passed、conditional_project_rename_tests 7 passed;agc:typecheck、lint:eslint(0 problems)、check:encoding、check:spacetime-schema、cargo fmt --check、git diff --check 全过。 --- .../src-tauri/src/asset_generation_tasks.rs | 196 ++++++++++++++- .../src-tauri/src/commands.rs | 22 ++ .../src/view/project-development/index.tsx | 142 ++++++----- .../tests/appSurface/home.suite.ts | 235 ++++++++++++++++++ .../shared-memory/decision-log.md | 4 +- docs/project-memory/shared-memory/pitfalls.md | 5 +- 6 files changed, 541 insertions(+), 63 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs index cdb03264e..3a1ae2797 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs @@ -569,9 +569,15 @@ pub(crate) fn enqueue_local_project_naming_task( /// /// 语义(命名任务独有的口径,素材任务一律拒绝): /// - 只作用于 `taskType = project-naming` 的记录,且 `task_id` 必须存在; +/// - 权限门禁与 [`enqueue_local_project_naming_task`] 同口径(`project.rename`),且在任何 +/// 读/改账本之前执行; +/// - 记录的 `project_id` 必须与项目 manifest 的 `project_id` 一致:目录换了项目(或身份被 +/// 改写)时一律返回 Err,不写盘; /// - `status` 只接受 `queued` / `running` / `completed` / `failed`; /// - 进入 `running` 时补 `started_at_millis`;进入终态时置 `finished_at_millis`; -/// - **已终态的记录不接受改回非终态**(返回 Err,避免命名结果被一次迟到的排队事件抹掉); +/// - **记录一旦终态,就只接受与其当前状态相同的写入(幂等重放)**;传入与当前状态不同的 +/// `status`(改回非终态,或改写成另一种终态)一律返回 Err 且不写盘——命名结果不能被一次 +/// 迟到的排队/失败事件覆盖; /// - `phase_detail` 传入即覆盖;`error` 只在 `failed` 时保存,非 `failed` 传入会被忽略; /// - live 集合与状态同步:非终态保持登记(在途任务不能被并发 `list` 收口成失败),终态摘除 /// (真中断的任务重启后仍能被收口)。落账失败只回滚本轮真正插入的那条登记。 @@ -586,6 +592,9 @@ pub(crate) fn update_local_project_naming_task( let task_id = asset_generation_task_id(&task_id)?; let status = naming_task_status(&status)?; let root = validated_local_project_directory_path(project_path.trim())?; + // 门禁必须在读/改账本之前;与 `enqueue_local_project_naming_task` 用同一个命令位。 + enforce_project_permission_policy(&root, "project.rename")?; + let project_id = read_existing_manifest_for_project(&root)?.project_id; let _guard = lock_ledger()?; let mut tasks = read_ledger(&root)?; let record = tasks @@ -595,8 +604,19 @@ pub(crate) fn update_local_project_naming_task( if record.task_type != ASSET_GENERATION_TASK_TYPE_PROJECT_NAMING { return Err(format!("生成任务不是项目命名任务:{task_id}")); } - if record.is_terminal() && !is_terminal_asset_generation_status(status) { - return Err(format!("项目命名任务已结束,不能改回非终态:{task_id}")); + // 记录必须属于当前项目:账本是项目内的,但 task_id 仍可能来自另一个项目(或目录被换过)。 + if record.project_id != project_id { + return Err(format!( + "项目命名任务记录不属于该项目:{task_id}(记录 {},当前项目 {project_id})", + record.project_id + )); + } + // 终态一旦落定就是终态:同状态的幂等重放仍允许,任何不同状态都拒绝且不写盘。 + if record.is_terminal() && record.status != status { + return Err(format!( + "项目命名任务已结束,不能再写入不同状态:{task_id}(当前 {},请求 {status})", + record.status + )); } record.status = status.to_string(); if status == ASSET_GENERATION_TASK_STATUS_RUNNING && record.started_at_millis.is_none() { @@ -1180,7 +1200,10 @@ mod asset_generation_task_tests { assert_eq!(record.project_id, "project-legacy"); assert_eq!(record.asset_name, "旧图"); assert_eq!(record.status, ASSET_GENERATION_TASK_STATUS_COMPLETED); - assert_eq!(record.phase_detail, ASSET_GENERATION_TASK_PHASE_COMPLETED); + // 兼容性只保证字段**原样保留**:`phaseDetail` 是 v1 时代后端自己写的字符串,读回必须逐字 + // 不变。它当年恰好等于某个 phase 常量并不构成契约——所以这里对着 fixture 自身的字面量 + // 断言,不引用当前 phase 常量来「证明兼容」。 + assert_eq!(record.phase_detail, "生成已完成。"); assert_eq!(record.output_path.as_deref(), Some("assets/legacy.png")); assert_eq!(record.created_at_millis, 11); assert_eq!(record.started_at_millis, Some(12)); @@ -1190,6 +1213,67 @@ mod asset_generation_task_tests { std::fs::remove_dir_all(&root).ok(); } + /// 不认识的 `schemaVersion` 必须失败关闭:宁可整份读不出来报错,也不能拿旧形状去猜读。 + #[test] + fn ledger_with_an_unsupported_schema_version_fails_closed() { + let root = temp_project_root("schema-unsupported"); + let ledger_path = root.join(ASSET_GENERATION_TASK_LEDGER_RELATIVE_PATH); + std::fs::create_dir_all(ledger_path.parent().expect("ledger parent")).expect("ledger dir"); + std::fs::write( + &ledger_path, + r#"{ + "schemaVersion": "agc-asset-generation-task.v3", + "tasks": [] + }"#, + ) + .expect("write unsupported ledger"); + + let error = read_ledger(&root).expect_err("unsupported schema must fail closed"); + assert!(error.contains("schema 版本不受支持"), "{error}"); + std::fs::remove_dir_all(&root).ok(); + } + + /// 缺失 `schemaVersion` 的老账本按 v1 接受:记录必须原样读回(`taskType` 缺省为素材任务)。 + #[test] + fn ledger_without_a_schema_version_is_accepted_as_v1() { + let root = temp_project_root("schema-missing"); + let ledger_path = root.join(ASSET_GENERATION_TASK_LEDGER_RELATIVE_PATH); + std::fs::create_dir_all(ledger_path.parent().expect("ledger parent")).expect("ledger dir"); + std::fs::write( + &ledger_path, + r#"{ + "tasks": [ + { + "taskId": "task-no-schema", + "projectId": "project-legacy", + "kind": "image", + "assetName": "无版本号旧图", + "status": "failed", + "phaseDetail": "旧版失败文案。", + "createdAtMillis": 21, + "startedAtMillis": 22, + "finishedAtMillis": 23, + "assetId": null, + "error": "旧版错误" + } + ] + }"#, + ) + .expect("write schema-less ledger"); + + let tasks = read_ledger(&root).expect("missing schemaVersion reads as v1"); + assert_eq!(tasks.len(), 1); + assert_eq!(tasks[0].task_id, "task-no-schema"); + assert_eq!( + tasks[0].task_type, + ASSET_GENERATION_TASK_TYPE_ASSET_GENERATION + ); + assert_eq!(tasks[0].kind.as_deref(), Some("image")); + assert_eq!(tasks[0].created_at_millis, 21); + assert_eq!(tasks[0].finished_at_millis, Some(23)); + std::fs::remove_dir_all(&root).ok(); + } + #[test] fn naming_task_enqueue_lands_in_the_shared_ledger_without_an_asset_kind() { let root = initialized_project_root("naming-enqueue"); @@ -1355,6 +1439,110 @@ mod asset_generation_task_tests { std::fs::remove_dir_all(&root).ok(); } + #[test] + fn naming_task_update_requires_the_project_rename_permission() { + let root = initialized_project_root("naming-update-permission"); + let project_path = naming_task_project_path(&root); + enqueue_local_project_naming_task(project_path.clone(), "naming-denied".to_string()) + .expect("enqueue"); + + let mut policy = crate::ProjectPermissionPolicy::default(); + policy.denied_commands.push("project.rename".to_string()); + write_project_permission_policy_at(&root, policy).expect("write permission policy"); + + let error = update_local_project_naming_task( + project_path, + "naming-denied".to_string(), + ASSET_GENERATION_TASK_STATUS_RUNNING.to_string(), + None, + None, + ) + .expect_err("denied permission must fail closed"); + assert!( + error.contains("项目权限策略拒绝执行:project.rename"), + "{error}" + ); + + // 门禁在读写账本之前:账本必须原封不动。 + let stored = read_ledger(&root).expect("read ledger"); + assert_eq!(stored[0].status, ASSET_GENERATION_TASK_STATUS_QUEUED); + remove_live_task_id("naming-denied"); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn naming_task_update_rejects_a_record_from_another_project() { + let root = initialized_project_root("naming-foreign-record"); + let project_path = naming_task_project_path(&root); + enqueue_local_project_naming_task(project_path.clone(), "naming-foreign".to_string()) + .expect("enqueue"); + + // 账本记录的项目身份被改成另一个项目(目录被换过 / task id 来自别的项目)。 + let mut stored = read_ledger(&root).expect("read ledger"); + stored[0].project_id = "another-project".to_string(); + write_ledger(&root, &stored).expect("write ledger"); + + let error = update_local_project_naming_task( + project_path, + "naming-foreign".to_string(), + ASSET_GENERATION_TASK_STATUS_RUNNING.to_string(), + None, + None, + ) + .expect_err("foreign record must fail closed"); + assert!(error.contains("记录不属于该项目"), "{error}"); + + // 拒绝发生在改动之前:状态仍是 queued。 + let stored = read_ledger(&root).expect("read ledger again"); + assert_eq!(stored[0].status, ASSET_GENERATION_TASK_STATUS_QUEUED); + remove_live_task_id("naming-foreign"); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn naming_task_terminal_state_rejects_a_different_status_but_allows_the_same_one() { + let root = initialized_project_root("naming-terminal"); + let project_path = naming_task_project_path(&root); + enqueue_local_project_naming_task(project_path.clone(), "naming-terminal".to_string()) + .expect("enqueue"); + update_local_project_naming_task( + project_path.clone(), + "naming-terminal".to_string(), + ASSET_GENERATION_TASK_STATUS_COMPLETED.to_string(), + Some("命名已完成。".to_string()), + None, + ) + .expect("completed"); + + // 终态 → 另一种终态:必须拒绝且不写盘(旧实现会静默把 completed 改写成 failed)。 + let error = update_local_project_naming_task( + project_path.clone(), + "naming-terminal".to_string(), + ASSET_GENERATION_TASK_STATUS_FAILED.to_string(), + Some("迟到的失败。".to_string()), + Some("迟到的失败".to_string()), + ) + .expect_err("terminal state must not be overwritten"); + assert!(error.contains("不能再写入不同状态"), "{error}"); + let stored = read_ledger(&root).expect("read ledger"); + assert_eq!(stored[0].status, ASSET_GENERATION_TASK_STATUS_COMPLETED); + assert_eq!(stored[0].phase_detail, "命名已完成。"); + assert!(stored[0].error.is_none()); + + // 同状态幂等重放仍允许。 + let replayed = update_local_project_naming_task( + project_path, + "naming-terminal".to_string(), + ASSET_GENERATION_TASK_STATUS_COMPLETED.to_string(), + None, + None, + ) + .expect("same-state replay is allowed"); + assert_eq!(replayed.status, ASSET_GENERATION_TASK_STATUS_COMPLETED); + assert_eq!(replayed.phase_detail, "命名已完成。"); + std::fs::remove_dir_all(&root).ok(); + } + #[test] fn interrupted_naming_task_is_settled_as_failed_with_naming_wording() { let root = initialized_project_root("naming-interrupted"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index e98373ab7..09c9c6400 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -704,6 +704,28 @@ mod conditional_project_rename_tests { assert_eq!(project_revision(root), revision_before); } + /// 超长名字同样走 `normalize_game_creation_project_name` 的门禁:条件改名必须失败关闭, + /// 且盘上 manifest 与 revision 都不动。 + #[test] + fn conditional_rename_rejects_an_over_long_name_without_touching_the_project() { + let project = fixture_root("conditional-rename-too-long"); + let root = project.path(); + let revision_before = project_revision(root); + + let too_long = "名".repeat(crate::project::GAME_CREATION_PROJECT_NAME_MAX_CHARS + 1); + let error = rename_local_game_project_if_unchanged_at( + root, + "conditional-rename-too-long", + FALLBACK_NAME, + &too_long, + ) + .expect_err("over-long name must fail closed"); + assert!(error.contains("项目名称"), "{error}"); + + assert_eq!(manifest_name(root), FALLBACK_NAME); + assert_eq!(project_revision(root), revision_before); + } + #[test] fn conditional_rename_rejects_empty_expectations_without_touching_the_project() { let project = fixture_root("conditional-rename-empty-expectation"); diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index d045dc33b..98d8dda41 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -8986,6 +8986,34 @@ export default function ProjectDevelopmentView({ [manifest.assets, resourceGenerationPlaceholders], ); + /** + * 刷新「AI 项目命名」行:从账本重读一次(受控快照读取)。 + * + * 读失败或返回值不是数组时**保留上一次的命名行**——一次 IPC 失败不该把列表清空。 + */ + const refreshProjectNamingTasks = useCallback(async () => { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + return; + } + try { + const response = await invoke( + 'list_local_project_asset_generations', + { projectPath }, + ); + if (!Array.isArray(response)) { + return; + } + setProjectNamingTasks( + splitLocalProjectGenerationTaskRecords(response).namingRecords.map( + restoreResourceCanvasProjectNamingTask, + ), + ); + } catch { + // 账本读不到:保留上一次的命名行,不猜状态。 + } + }, [projectPath]); + /** * 重开项目时恢复项目内的任务账本。 * @@ -9003,6 +9031,7 @@ export default function ProjectDevelopmentView({ } const projectId = manifest.projectId; let cancelled = false; + let unsubscribeNamingTasks: (() => void) | null = null; setResourceAssetGenerationTasksPanelOpen(false); setProjectNamingTasks([]); resourceAssetGenerationPanelSubmissionRef.current = null; @@ -9016,6 +9045,39 @@ export default function ProjectDevelopmentView({ resourceGenerationDraftRef.current.clear(); resourceAssetGenerationDraftRef.current.clear(); void (async () => { + /** + * **先订阅、再读快照**(与素材队列「先订阅后派发」同一口径)。 + * + * 命名行的推进发生在本组件之外(首页后台命名链路),刷新只能靠「变更事件 + 一次受控快照 + * 读取」;读取是异步的,若先读后订阅,落在这一次 IPC 往返里的终态事件没有消费者、直接丢掉, + * 命名行会永久停在「命名中」。订阅失败也要照读一次,靠面板打开时的补读兜底。 + * + * 订阅与这次恢复读共用同一次 effect:本项目挂载时对账本的读次数保持不变(把命名读并进 + * 这一次恢复读),不改变既有生成任务链路可观测的轮询计数。 + */ + if (canSubscribeTauriEvents()) { + try { + const dispose = await subscribeTauriEvent<{ projectPath?: string }>( + RESOURCE_CANVAS_ASSET_GENERATION_CHANGED_EVENT, + (event) => { + if (cancelled || event.payload?.projectPath !== projectPath) { + return; + } + void refreshProjectNamingTasks(); + }, + ); + if (cancelled) { + dispose(); + return; + } + unsubscribeNamingTasks = dispose; + } catch { + // 订阅不上:退化成「进项目读一次 + 面板打开时重读」。 + } + } + if (cancelled) { + return; + } const reportUnavailable = () => { if (!cancelled) { setResourceWorkbenchNotice( @@ -9057,70 +9119,38 @@ export default function ProjectDevelopmentView({ })(); return () => { cancelled = true; + unsubscribeNamingTasks?.(); }; - }, [manifest.projectId, projectPath]); + }, [manifest.projectId, projectPath, refreshProjectNamingTasks]); /** - * 刷新「AI 项目命名」行:从账本重读一次(受控快照读取)。 + * 面板打开时补一次快照读。 * - * 读失败或返回值不是数组时**保留上一次的命名行**——一次 IPC 失败不该把列表清空。 - */ - const refreshProjectNamingTasks = useCallback(async () => { - const invoke = window.__TAURI__?.core?.invoke; - if (!invoke) { - return; - } - try { - const response = await invoke( - 'list_local_project_asset_generations', - { projectPath }, - ); - if (!Array.isArray(response)) { - return; - } - setProjectNamingTasks( - splitLocalProjectGenerationTaskRecords(response).namingRecords.map( - restoreResourceCanvasProjectNamingTask, - ), - ); - } catch { - // 账本读不到:保留上一次的命名行,不猜状态。 - } - }, [projectPath]); - - /** - * 「AI 项目命名」的推进发生在本组件之外(首页后台命名链路),所以这里只订阅账本变更事件, - * 在事件属于当前项目时做**一次**受控快照读取(团队口径:正式状态变化走事件 + 一次快照读取, - * 不新增固定频率轮询)。素材任务仍按各自既有的账本/队列路径推进,不在这里改。 + * 这是事件之外的兜底:订阅尚未就绪、窗口刚恢复、或用户就是想确认进度时,打开面板就能看到 + * 账本里的真实状态,而不是永久停在「命名中」。 + * + * **只在列表里确实存在一条在途命名行时才读**:没有命名任务的项目(绝大多数)不该因为打开 + * 面板就多打一次账本 IPC——那会改变「轮询计数」这类外部可观测行为(既有生成任务用例正是 + * 按轮询次数推进 mock 的)。依赖的是布尔而不是行数组:读取结果只会让它在 true→false 之间 + * 最多翻转一次,不会自激。 */ + const hasInFlightProjectNamingTask = projectNamingTasks.some( + (task) => task.status !== 'completed' && task.status !== 'failed', + ); useEffect(() => { - if (!canSubscribeTauriEvents() || !projectPath.trim()) { + if ( + !resourceAssetGenerationTasksPanelOpen || + !hasInFlightProjectNamingTask + ) { return undefined; } - let disposed = false; - let unsubscribe: (() => void) | null = null; - void subscribeTauriEvent<{ projectPath?: string }>( - RESOURCE_CANVAS_ASSET_GENERATION_CHANGED_EVENT, - (event) => { - if (disposed || event.payload?.projectPath !== projectPath) { - return; - } - void refreshProjectNamingTasks(); - }, - ) - .then((dispose) => { - if (disposed) { - dispose(); - } else { - unsubscribe = dispose; - } - }) - .catch(() => undefined); - return () => { - disposed = true; - unsubscribe?.(); - }; - }, [projectPath, refreshProjectNamingTasks]); + void refreshProjectNamingTasks(); + return undefined; + }, [ + hasInFlightProjectNamingTask, + resourceAssetGenerationTasksPanelOpen, + refreshProjectNamingTasks, + ]); /** * 「生成任务」面板里点一条已完成任务:复用既有聚焦链定位到它的素材卡。 diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 3864f4779..28e65c837 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -1999,6 +1999,241 @@ export function registerHomeProjectCreationTests() { expect(screen.queryByText('GameAgent 项目 1a2b3c4d')).toBeNull(); }); + it('recovers the naming row when the terminal event lands before the subscription is ready', async () => { + const projectPath = '/tmp/home-naming-event-race'; + const fallbackManifest = createGameCreationAppManifest( + 'home-naming-event-race', + 'GameAgent 项目 5f6a7b8c', + ); + const renamedManifest = { ...fallbackManifest, name: '星轨夜航' }; + const ledgerRecord = (status: string, phaseDetail: string) => [ + { + taskId: `project-naming:${fallbackManifest.projectId}`, + projectId: fallbackManifest.projectId, + taskType: 'project-naming', + kind: null, + assetName: 'AI 项目命名', + status, + phaseDetail, + createdAtMillis: 1, + startedAtMillis: 2, + finishedAtMillis: status === 'completed' ? 3 : null, + assetId: null, + error: null, + }, + ]; + let namingSettled = false; + // 订阅注册被闸门卡住:这一段里落地的事件在真实世界里没有订阅者——就是丢了。 + let releaseSubscription: (() => void) | null = null; + let subscriptionReady = false; + let droppedWhileUnsubscribed = 0; + const chatHarness = createProjectChatRuntimeHarness({ + projectPath, + initialSessionExists: false, + }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'create_automatic_local_game_project') { + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest: fallbackManifest, + }; + } + if (command === 'suggest_automatic_project_name') return '星轨夜航'; + if (command === 'rename_local_game_project_if_unchanged') { + namingSettled = true; + return { renamed: true, manifest: renamedManifest, revision: 2 }; + } + if (command === 'get_local_game_manifest') { + return namingSettled ? renamedManifest : fallbackManifest; + } + if (command === 'list_local_project_asset_generations') { + // 进项目那一次读拿到的是「老化快照」(响应在命名落定之前就算好了); + // 订阅就绪之后的读才是账本真相。 + return namingSettled && subscriptionReady + ? ledgerRecord('completed', '已应用 AI 名称「星轨夜航」。') + : ledgerRecord('running', '正在等待 AI 命名结果。'); + } + if (command === 'inspect_local_project_directory') { + return { + projectPath: String(args?.projectPath ?? ''), + exists: true, + isDirectory: true, + isGameCreatorProject: true, + isCocosProject: false, + godotProjectRoot: null, + projectName: renamedManifest.name, + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'enqueue_direct_codex_turn') { + chatHarness.completeDirectThreadTurn({ + turnId: String(args?.clientTurnId ?? ''), + prompt: '做一个星轨游戏', + reply: '开工。', + }); + return '开工。'; + } + return chatHarness.invoke(command, args); + }, + ); + const listen = ( + eventName: string, + handler: (event: { payload: unknown }) => void, + ) => { + if (eventName !== 'game-creator-asset-generation-task-changed') { + return chatHarness.listen(eventName, handler); + } + return new Promise<() => void>((resolve) => { + releaseSubscription = () => { + subscriptionReady = true; + resolve(() => {}); + }; + }); + }; + installTauriRuntime({ core: { invoke }, event: { listen } }); + renderLauncherAt('/?launcher', 'home', true); + + const promptInput = screen.getByLabelText('创作想法'); + nativeClipboardMock.text = '做一个星轨游戏'; + fireEvent.paste(promptInput); + await waitFor(() => { + expect(promptInput.textContent).toContain('做一个星轨游戏'); + }); + fireEvent.keyDown(promptInput, { key: 'Enter', code: 'Enter' }); + await screen.findByLabelText('项目开发工作台'); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'rename_local_game_project_if_unchanged', + expect.anything(), + ); + }); + + // 次序:读返回(陈旧 running)→ 终态事件(此时没有订阅者,丢掉)→ 订阅才注册。 + droppedWhileUnsubscribed += 1; + await act(async () => { + releaseSubscription?.(); + }); + + // 命名行必须到终态:右上角开关的在途计数归零(旧实现会永久停在「生成任务 · 1」)。 + await waitFor(() => { + expect(droppedWhileUnsubscribed).toBe(1); + expect(screen.getByLabelText('生成任务').textContent).toBe('生成任务'); + }); + }); + + it('re-reads the naming ledger when the generation tasks panel opens', async () => { + const projectPath = '/tmp/home-naming-panel-reread'; + const fallbackManifest = createGameCreationAppManifest( + 'home-naming-panel-reread', + 'GameAgent 项目 6a7b8c9d', + ); + const renamedManifest = { ...fallbackManifest, name: '星轨夜航' }; + const ledgerRecord = (status: string, phaseDetail: string) => [ + { + taskId: `project-naming:${fallbackManifest.projectId}`, + projectId: fallbackManifest.projectId, + taskType: 'project-naming', + kind: null, + assetName: 'AI 项目命名', + status, + phaseDetail, + createdAtMillis: 1, + startedAtMillis: 2, + finishedAtMillis: status === 'completed' ? 3 : null, + assetId: null, + error: null, + }, + ]; + let ledgerSettled = false; + const chatHarness = createProjectChatRuntimeHarness({ + projectPath, + initialSessionExists: false, + }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'create_automatic_local_game_project') { + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest: fallbackManifest, + }; + } + if (command === 'suggest_automatic_project_name') return '星轨夜航'; + if (command === 'rename_local_game_project_if_unchanged') { + return { renamed: true, manifest: renamedManifest, revision: 2 }; + } + if (command === 'get_local_game_manifest') return renamedManifest; + if (command === 'list_local_project_asset_generations') { + // 账本真相由 `ledgerSettled` 控制:打开面板之前它是陈旧快照, + // 之后会重读一次拿到终态(本用例里订阅直接失败,只剩这条兜底)。 + return ledgerSettled + ? ledgerRecord('completed', '已应用 AI 名称「星轨夜航」。') + : ledgerRecord('running', '正在等待 AI 命名结果。'); + } + if (command === 'inspect_local_project_directory') { + return { + projectPath: String(args?.projectPath ?? ''), + exists: true, + isDirectory: true, + isGameCreatorProject: true, + isCocosProject: false, + godotProjectRoot: null, + projectName: renamedManifest.name, + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'enqueue_direct_codex_turn') { + chatHarness.completeDirectThreadTurn({ + turnId: String(args?.clientTurnId ?? ''), + prompt: '做一个星轨游戏', + reply: '开工。', + }); + return '开工。'; + } + return chatHarness.invoke(command, args); + }, + ); + const listen = ( + eventName: string, + handler: (event: { payload: unknown }) => void, + ) => { + if (eventName === 'game-creator-asset-generation-task-changed') { + // 订阅失败:这条链路只剩「面板打开时补读」这一个兜底。 + return Promise.reject(new Error('subscription unavailable')); + } + return chatHarness.listen(eventName, handler); + }; + installTauriRuntime({ core: { invoke }, event: { listen } }); + renderLauncherAt('/?launcher', 'home', true); + + const promptInput = screen.getByLabelText('创作想法'); + nativeClipboardMock.text = '做一个星轨游戏'; + fireEvent.paste(promptInput); + await waitFor(() => { + expect(promptInput.textContent).toContain('做一个星轨游戏'); + }); + fireEvent.keyDown(promptInput, { key: 'Enter', code: 'Enter' }); + await screen.findByLabelText('项目开发工作台'); + // 陈旧快照下命名行还在跑:开关上带着在途计数。 + await waitFor(() => { + expect(screen.getByLabelText('生成任务').textContent).toContain( + '生成任务 · 1', + ); + }); + // 账本随后落定,但(订阅失败的场景里)这条链路收不到任何事件。 + ledgerSettled = true; + + fireEvent.click(screen.getByLabelText('生成任务')); + // 打开面板补一次读:行必须变成账本里的终态,而不是永远停在「命名中」。 + expect( + await screen.findByText('已应用 AI 名称「星轨夜航」。'), + ).not.toBeNull(); + }); + it('keeps the home composer out of chat mode while automatic project creation is pending', async () => { let rejectAutomaticProject: ((error: Error) => void) | null = null; const automaticProject = new Promise((_resolve, reject) => { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 6cd442271..e4ad91b0e 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5,9 +5,9 @@ - 背景:首页「开启创作」原先串行执行「Web 预检 → `await suggest_automatic_project_name` → `create_automatic_local_game_project`」。项目名称不是创建工作区、导入附件或发起首轮创作的前置条件,命名请求(`AUTOMATIC_PROJECT_NAME_TIMEOUT_MS = 15s`,正常请求同样占时)却把用户按在「正在创建工作区」上。 - 决策(建项与命名解耦):建项固定传 `name: null`,由宿主既有兜底名(`GameAgent 项目 <8 位短 id>` / `策划项目 <8 位短 id>`)落盘并立即进入项目;命名请求在建项前并行打出、结果交给后台任务。后台拿到合法名称后调用新增的 `rename_local_game_project_if_unchanged(projectPath, expectedProjectId, expectedName, name)`:只有「项目 ID 相同」且「当前名称仍是本次创建的兜底名」才改名,返回 `renamed: true/false`(跳过时不写盘、不是错误)。用户已手动改名、项目 ID 不符、名称与现状相同一律跳过;空 `expectedProjectId` / `expectedName`、空名 / 控制字符 / 超长一律失败关闭。 - 决策(纳入「生成任务」体系):自动命名做成**项目工作台那条既有「生成任务」列表里的一条后台任务**,而不只是页面里的一个 promise。账本记录加 `taskType`(`asset-generation` / `project-naming`,缺省 `asset-generation`)、`kind` 变可选,schema 升 `agc-asset-generation-task.v2`(读取同时接受 v1/v2,v1 记录按素材任务读回);新增 `enqueue_local_project_naming_task`(排队中)/ `update_local_project_naming_task`(命名中 → 已完成/失败),展示名固定「AI 项目命名」,与素材生成共用同一份账本、同一个变更事件与同一个侧栏。状态流转:排队中 → 命名中 → 已完成(已应用 AI 名称 / 建议名与现状一致 / 用户已手动改名而跳过)或失败(无可用名称 / 自动改名失败,均保留兜底名)。命名任务由命名链路推进、**不进 live 集合就会被中断收口误判**,所以 enqueue 写账本前登记 live、update 终态写盘后摘除(中断残留仍按命名口径收口为失败)。前端按 `taskType` 把命名记录从素材任务分支里剔除,命名行不渲染缩略图/提示词/派发与定位动作,结论只在终态显示。 -- 边界:改名是簿记写入,与既有 `rename_local_game_project` 同口径**不推进项目 revision**(推 revision 会让运行时验证凭证无故漂移);返回的 `revision` 是当时盘上的值。后台改名结果写「当前项目上下文」与「最近项目行重检」必须等建项主体收尾(`entrySettled`):AI 比进项目更快时直接写上下文会被随后的 `enterProjectDevelopment` 用兜底名覆盖,最近项目行也要等进项目登记过才会被重检;写入前再过壳的生命周期守卫(`mounted` + 代次),关窗/卸载后只保留已落盘的改名,不写 UI 投影。账本是**展示旁路**:`enqueue` / `update` 失败只写诊断日志,绝不影响建项、命名与首轮创作;「做方案」与手动选目录建项不发起自动命名。本次未改共享契约(`packages/shared/**`)、server-rs 与任何 SpacetimeDB schema/HTTP 路由。 +- 边界:改名是簿记写入,与既有 `rename_local_game_project` 同口径**不推进项目 revision**(推 revision 会让运行时验证凭证无故漂移);返回的 `revision` 是当时盘上的值。后台改名结果写「当前项目上下文」与「最近项目行重检」必须等建项主体收尾(`entrySettled`):AI 比进项目更快时直接写上下文会被随后的 `enterProjectDevelopment` 用兜底名覆盖,最近项目行也要等进项目登记过才会被重检;写入前再过壳的生命周期守卫(`mounted` + 代次),关窗/卸载后只保留已落盘的改名,不写 UI 投影。账本是**展示旁路**:`enqueue` / `update` 失败只写诊断日志,绝不影响建项、命名与首轮创作;「做方案」与手动选目录建项不发起自动命名。本次未改共享契约(`packages/shared/**`)、server-rs 与任何 SpacetimeDB schema/HTTP 路由。条件改名的 `expectedProjectId` / `expectedName` 为空时按仓库同类入口口径失败关闭;`update_local_project_naming_task` 与 enqueue 共用 `project.rename` 权限位、校验记录归属,且终态只接受同状态幂等重放。**兼容性写成显式边界:兼容是单向的**——新构建读 v1 账本 OK;旧构建读到含命名记录(`kind: null`)的账本会整份解析失败(面板报读失败、同批在途素材任务被按中断收口)。前端刷新必须先订阅事件再读快照,并在面板打开时补读一次兜底。 - 影响范围:`apps/ai-game-creator-shell/src-tauri/src/{commands.rs,desktop.rs,asset_generation_tasks.rs,asset_generation_tasks/runtime.rs}`、`apps/ai-game-creator-shell/src/{app/types.ts,features/app-shell/{useHomeProjectCreation.ts,WorkspaceLauncher.tsx},features/resource-canvas/{resourceCanvasAssetGenerationTaskModel.ts,ResourceCanvasAssetGenerationTasksPanelView.tsx},view/project-development/index.tsx}`、`apps/ai-game-creator-shell/tests/{homeProjectNamingAsync.test.tsx,projectNamingGenerationTaskRow.test.tsx,appSurface/home.suite.ts}`。 -- 验证:`npx vitest run apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx`(12 passed,含「命名请求永不返回仍进工作区」「AI 结果先于进项目落定仍不被兜底名覆盖」「手动改名不被覆盖且任务按已完成+已跳过收口」「非法/空响应 → 任务 failed 且保留兜底名」「做方案不发起命名」「切到别的工作区不被劫持」「卸载/pagehide 后不写上下文」,并断言入队→命名中→终态的账本推进序列);`projectNamingGenerationTaskRow.test.tsx`(3 passed:固定展示名/不渲染缩略图提示词定位、终态才显示结论、失败徽章与原因 + 在途计数);AGC 全量 `npm run test -- apps/ai-game-creator-shell/tests`(1925 passed / 17 skipped);`cargo test --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute asset_generation_task`(22 passed,含 `naming_task_stays_in_flight_across_ledger_reads_until_terminal` 与 `legacy_v1_ledger_reads_back_with_the_default_asset_generation_task_type`)与 `conditional_project_rename_tests`(6 passed);`npm run agc:typecheck`(含 `check:tests:types`)、`cargo fmt --check`、`npm run check:encoding`、`git diff --check`。 +- 验证:`npx vitest run apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx`(12 passed,含「命名请求永不返回仍进工作区」「AI 结果先于进项目落定仍不被兜底名覆盖」「手动改名不被覆盖且任务按已完成+已跳过收口」「非法/空响应 → 任务 failed 且保留兜底名」「做方案不发起命名」「切到别的工作区不被劫持」「卸载/pagehide 后不写上下文」,并断言入队→命名中→终态的账本推进序列);`projectNamingGenerationTaskRow.test.tsx`(3 passed:固定展示名/不渲染缩略图提示词定位、终态才显示结论、失败徽章与原因 + 在途计数);AGC 全量 `npm run test -- apps/ai-game-creator-shell/tests`(1964 passed / 17 skipped);`cargo test --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute asset_generation_task`(27 passed,含 `naming_task_stays_in_flight_across_ledger_reads_until_terminal`、`naming_task_update_requires_the_project_rename_permission`、`naming_task_update_rejects_a_record_from_another_project`、`naming_task_terminal_state_rejects_a_different_status_but_allows_the_same_one`、`ledger_with_an_unsupported_schema_version_fails_closed`、`ledger_without_a_schema_version_is_accepted_as_v1`)与 `conditional_project_rename_tests`(7 passed,含超长名失败关闭);`npm run agc:typecheck`(含 `check:tests:types`)、`cargo fmt --check`、`npm run check:encoding`、`git diff --check`。 ## 2026-10-03 AGC 发布版本标签改为由工程内部版本派生,取代「用户可编辑标签」口径 - 背景:用户实机验收指出发布面板「项目版本」显示 v6,而 AGC 工程内部只有 4 条正式版本记录(资源总览「项目版本」栏目 4 张卡,顶栏「智能体修订」下拉同样只有这 4 条)。核实:面板值来自本地清单 `manifest.projectVersion` 这个可编辑标量,它被三条链路反复钉到**平台** `game_distribution_version.version_number` 上——发布成功回写(`apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs:1531-1535`)、打开面板回读绑定回填(`:536-541`)、用户手改(`:944-965`);而 `manifest.versions` 从头到尾不参与该值。`publicationRevision` 只做 CAS,与任何版本号都无推导关系(`module-game-distribution/src/domain.rs:27-40` 的版本号解析只比 `max_existing` 与 `requested`)。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 4f429d2c1..ff263e55f 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -8,7 +8,10 @@ - **根因**:账本的中断收口判据是「非终态 + 不在本进程 live 集合」= 上次运行的残留。素材生成任务由 Rust 后台任务在 `start` 里先登记 live 再落账本;项目命名任务由前端驱动(`enqueue` / `update` 两条 IPC),没有登记 live,于是任何一次 `list_local_project_asset_generations` 都把它当成残留收口。 - **处理(现行口径)**:账本记录加 `taskType`(`asset-generation` / `project-naming`,缺省 `asset-generation`)、`kind` 变可选;schema 升 `agc-asset-generation-task.v2`,读取同时接受 v1 与 v2(v1 记录按素材任务读回)。`enqueue_local_project_naming_task` 写账本前登记 live(写盘失败只回滚本轮插入的那条),`update_local_project_naming_task` 在非终态时幂等登记、终态写盘成功后摘除。命名没有素材可交叉核对,中断收口一律 failed 且文案与素材口径区分(不含「目标素材」)。 - **边界**:命名任务的结论文案(已应用 / 用户已改名跳过 / 失败保留兜底名)由命名链路给出;账本只是展示旁路——`enqueue` / `update` 失败只写诊断日志,绝不影响建项、命名与首轮创作。前端按 `taskType` 把命名记录从素材任务列表里剔除(素材分支会按 `kind` 解析),命名行固定展示名「AI 项目命名」,只在终态显示结论。 -- **判据/取证**:Rust `cargo test … asset_generation_task` 22 条(含 `naming_task_stays_in_flight_across_ledger_reads_until_terminal`、`legacy_v1_ledger_reads_back_with_the_default_asset_generation_task_type`、`interrupted_naming_task_is_settled_as_failed_with_naming_wording`);前端 `homeProjectNamingAsync.test.tsx`(入队→running→终态的推进序列)、`projectNamingGenerationTaskRow.test.tsx`(行渲染)、appSurface 的「syncs the workbench title and recent project list with the AI name after creation」(建项后行可见 + 事件驱动刷新出终态结论)。 +- **边界(兼容是单向的,必须写清)**:**新构建读旧账本 OK,旧构建读新账本会失败**——命名记录写 `"kind": null`,而旧构建(`AssetGenerationTaskRecord.kind` 必填且 `read_ledger` 不校验 schema)解析含命名记录的账本会**整份失败** → 面板报「生成任务列表读取失败」,且同一次读里在途的素材任务会被按「上次运行中断」收口为失败。触发条件只有「同一项目先被新构建写过命名任务、之后又被旧构建打开(降级或新旧混跑)」。潜在的低成本正向兼容方向(**未实施,需先确认**):命名记录也写一个合法 `kind`(例如 `unknown`)只靠 `taskType` 区分——旧构建会把它渲染成一张名为「AI 项目命名」的素材任务卡,但账本能解析、不会误伤在途素材任务。 +- **边界(前端读快照的时序)**:命名行的推进发生在本组件之外,刷新只能靠「变更事件 + 一次受控快照读取」。**必须先订阅再读**(与素材队列「先订阅后派发」同口径):先读后订阅时,落在这一次 IPC 往返里的终态事件没有消费者、直接丢掉,行会永久停在「命名中」;订阅失败时退化为「进项目读一次 + 面板打开时补读」。补读兜底是必须的(订阅不可用、窗口刚恢复、用户想确认进度)。 +- **边界(update 的门禁与终态语义)**:`update_local_project_naming_task` 与 `enqueue` 共用 `project.rename` 权限位,并校验记录 `project_id` 与项目一致;记录一旦终态只接受**同状态幂等重放**,改写成另一种状态(含 `completed → failed`)一律 Err 且不写盘——否则一次迟到的排队/失败事件会覆盖已落定的命名结论。 +- **判据/取证**:Rust `cargo test … asset_generation_task` 27 条(含 `naming_task_stays_in_flight_across_ledger_reads_until_terminal`、`naming_task_update_requires_the_project_rename_permission`、`naming_task_update_rejects_a_record_from_another_project`、`naming_task_terminal_state_rejects_a_different_status_but_allows_the_same_one`、`ledger_with_an_unsupported_schema_version_fails_closed`、`ledger_without_a_schema_version_is_accepted_as_v1`)与 `conditional_project_rename_tests` 7 条(含超长名失败关闭);前端 `homeProjectNamingAsync.test.tsx`(入队→running→终态的推进序列)、`projectNamingGenerationTaskRow.test.tsx`(行渲染)、appSurface 的「syncs the workbench title and recent project list with the AI name after creation」「recovers the naming row when the terminal event lands before the subscription is ready」「re-reads the naming ledger when the generation tasks panel opens」(后两条已做「改前红」验证:退回「先读后订阅 / 面板打开不补读」两条即红)。 - **关联**:`apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs`、`apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts`、`apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts`、`apps/ai-game-creator-shell/src/view/project-development/index.tsx`。 ## 2026-10-03 后台异步回填被「进项目」覆盖:AI 项目名只在兜底名仍成立时改