From ca6a3cd4cdb0bf1f89f2d7bf6777f3f89558ea85 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 19 Aug 2026 09:13:43 +0000 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DM1=E7=AD=96=E5=88=92=E9=93=BE?= =?UTF-8?q?=E8=B7=AF=E5=8F=AF=E9=9D=A0=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 阻止人工核对中的策划根写入审批回执 在投影恢复前校验项目身份并使用唯一项目ID 增加策划能力停用门禁与既有sidecar只读行为 拒绝非法plan与自主构建档位组合并收紧消费者 补齐前后端回归测试与M1技术决策记录 --- .../game-creator.config.json | 5 +- .../provider_request_builders.rs | 53 ++--- .../src-tauri/src/agent/runtime_driver.rs | 9 + .../src/agent/runtime_driver/task_start.rs | 7 +- .../runtime_protocol/autonomous_completion.rs | 12 +- .../autonomous_completion_contract_tests.rs | 23 ++- .../runtime_protocol/planning_approval.rs | 43 ++++ .../runtime_protocol/planning_hydrate.rs | 184 +++++++++++++++--- .../runtime_protocol/planning_storage.rs | 23 +++ .../agent/runtime_protocol/planning_submit.rs | 115 +++++++++++ .../runtime_protocol/run_configuration.rs | 3 +- .../src-tauri/src/commands.rs | 5 + .../src-tauri/src/config.rs | 9 + .../src-tauri/src/main.rs | 31 +++ .../src-tauri/src/tests/configuration.rs | 12 ++ apps/ai-game-creator-shell/src/App.tsx | 5 +- .../src/app/constants.ts | 4 + apps/ai-game-creator-shell/src/app/types.ts | 3 + .../app-shell/useHomeProjectCreation.ts | 8 +- .../tests/appSurface/home.suite.ts | 4 +- .../appSurface/project-commands.suite.ts | 10 +- .../appSurface/project-development.suite.ts | 4 +- .../shared-memory/decision-log.md | 8 + ...方案】立项策划Agent(Fast GDD)-2026-08-10.md | 2 +- 24 files changed, 482 insertions(+), 100 deletions(-) diff --git a/apps/ai-game-creator-shell/game-creator.config.json b/apps/ai-game-creator-shell/game-creator.config.json index ae9d1bedf..2b3f24be6 100644 --- a/apps/ai-game-creator-shell/game-creator.config.json +++ b/apps/ai-game-creator-shell/game-creator.config.json @@ -20,5 +20,8 @@ "baseUrl": "http://127.0.0.1:8082", "apiKey": "" }, - "mcpServers": {} + "mcpServers": {}, + "planning": { + "capabilityEnabled": true + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs index 44467b6c5..c3e008db8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs @@ -780,6 +780,7 @@ mod tests { build_game_creator_agent_background_final_reply_request, build_game_creator_agent_background_tool_plan_request, game_creator_agent_context_preload_notice, game_creator_agent_runtime_role_overlay_prompt, + game_creator_agent_runtime_run_profile_binding_path, game_creator_project_supervisor_chat_system_prompt, init_local_game_project_at, new_game_creation_app_seed_tasks, provider_command_exec_contract, provider_command_start_contract, render_autonomous_manifest_ready_task_background_prompt, @@ -787,11 +788,13 @@ mod tests { start_game_creator_agent_runtime_task_at, AgentRuntimeGoalContractAcceptanceNodeDraft, AgentRuntimeGoalContractDraft, AgentRuntimeTaskLink, AgentRuntimeToolObservation, AgentRuntimeToolPlan, GameCreatorMcpCatalog, GameCreatorMcpCatalogTool, - AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL, AGENT_RUNTIME_RESPOND_FUNCTION_NAME, - AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, AGENT_RUNTIME_RUN_PROFILE_STANDARD, - AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, - AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL, + AGENT_RUNTIME_PLAN_AUTONOMOUS_PROFILE_UNSUPPORTED_KIND, + AGENT_RUNTIME_RESPOND_FUNCTION_NAME, AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, + AGENT_RUNTIME_RUN_PROFILE_STANDARD, AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, GAME_CREATOR_PROJECT_PLANNING_FINAL_REPLY_SYSTEM_PROMPT, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, PLAN_FAST_GDD_ACCEPTANCE_NODE_CRITERION, PLAN_FAST_GDD_ACCEPTANCE_NODE_EVIDENCE, PLAN_FAST_GDD_ACCEPTANCE_NODE_ID, @@ -1461,7 +1464,7 @@ mod tests { let root = directory.path().join("project"); init_local_game_project_at(&root, "plan-profile-drift", "形成 Fast GDD") .expect("project init"); - let binding = bind_game_creator_agent_runtime_run_profile_at( + let error = bind_game_creator_agent_runtime_run_profile_at( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, "provider-plan-profile-drift-root", @@ -1469,33 +1472,17 @@ mod tests { Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), None, ) - .expect("bind deliberately drifted plan root"); - let state = start_game_creator_agent_runtime_task_at( - &root, - &binding.agent_id, - "形成 Fast GDD", - &binding.run_id, - &binding.source, - "冻结立项目标", - Vec::new(), - ) - .expect("start drifted plan root"); - let error = build_game_creator_agent_background_tool_plan_request( - &root, - &state.agent_id, - &state.session_id, - &state.run_id, - &state.current_task, - &[], - 0, - &GameCreatorMcpCatalog { - fingerprint: String::new(), - servers: Vec::new(), - tools: Vec::new(), - }, - ) - .expect_err("plan source must pass the shared strong predicate"); - assert!(error.contains("project-supervisor-plan standard 顶层根 Run")); + .expect_err("plan source/profile drift must be rejected before it becomes durable"); + assert!(error.contains(AGENT_RUNTIME_PLAN_AUTONOMOUS_PROFILE_UNSUPPORTED_KIND)); + assert!( + !game_creator_agent_runtime_run_profile_binding_path( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "provider-plan-profile-drift-root", + ) + .exists(), + "非法 plan/autonomous binding 不得落盘" + ); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs index 54f28ac82..c268911a0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs @@ -225,6 +225,15 @@ pub(crate) fn agent_runtime_supervisor_source_is_trusted(source: &str) -> bool { ) } +pub(crate) fn agent_runtime_supervisor_source_is_autonomous_game_build(source: &str) -> bool { + matches!( + source.trim(), + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE + | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE + | AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + ) +} + pub(crate) fn agent_runtime_supervisor_source_is_plan(source: &str) -> bool { source.trim() == AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs index cffb133b0..9fb62c210 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs @@ -128,6 +128,11 @@ pub(crate) fn start_game_creator_supervisor_background_task_for_session_at( } let run_profile = normalize_agent_runtime_run_profile(Some(run_profile))?; reject_supervisor_plan_autonomous_profile(source, &run_profile)?; + if agent_runtime_supervisor_source_is_plan(source) + && !crate::config::game_creator_planning_capability_enabled()? + { + return Err("PLAN_CAPABILITY_DISABLED: 立项策划能力当前已停用".to_string()); + } start_game_creator_agent_background_task_with_source_at( root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, @@ -848,7 +853,7 @@ pub(crate) fn current_autonomous_game_build_root_task_at( && record.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && record.parent_agent_id.is_none() && record.parent_run_id.is_none() - && agent_runtime_supervisor_source_is_trusted(&record.source) + && agent_runtime_supervisor_source_is_autonomous_game_build(&record.source) && seen_run_ids.insert(record.run_id.clone()) { root_run_ids.push(record.run_id.clone()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs index a84fdcfa8..9249a7998 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs @@ -38,7 +38,7 @@ pub(crate) fn autonomous_game_build_root_run_active_at(root: &Path) -> bool { if read_game_creator_agent_runtime_at(root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID).is_ok_and( |runtime| { runtime.state.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD - && agent_runtime_supervisor_source_is_trusted(&runtime.state.source) + && agent_runtime_supervisor_source_is_autonomous_game_build(&runtime.state.source) && !matches!( runtime.state.phase.as_str(), "completed" | "failed" | "cancelled" | "budget-exhausted" @@ -55,7 +55,7 @@ pub(crate) fn autonomous_game_build_root_run_active_at(root: &Path) -> bool { tasks.into_iter().any(|task| { task.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && task.parent_run_id.is_none() - && agent_runtime_supervisor_source_is_trusted(&task.source) + && agent_runtime_supervisor_source_is_autonomous_game_build(&task.source) && matches!( task.status.as_str(), "pending" @@ -7075,7 +7075,7 @@ pub(in crate::agent) fn validate_autonomous_completion_contract( || binding.parent_run_id.is_some() || binding.root_agent_id != contract.agent_id || binding.root_run_id != contract.run_id - || !agent_runtime_supervisor_source_is_trusted(&binding.source) + || !agent_runtime_supervisor_source_is_autonomous_game_build(&binding.source) { return Err("自主构建完成合同与根 Supervisor Run 不匹配".to_string()); } @@ -7202,7 +7202,7 @@ fn failed_terminal_autonomous_root_contract_before_task_at( root: &Path, task: &AgentRuntimeTaskRecord, ) -> Result, String> { - if !agent_runtime_supervisor_source_is_trusted(&task.source) + if !agent_runtime_supervisor_source_is_autonomous_game_build(&task.source) || !is_pure_autonomous_continuation_intent(&task.task) { return Ok(None); @@ -7219,7 +7219,7 @@ fn failed_terminal_autonomous_root_contract_before_task_at( && record.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && record.parent_agent_id.is_none() && record.parent_run_id.is_none() - && agent_runtime_supervisor_source_is_trusted(&record.source) + && agent_runtime_supervisor_source_is_autonomous_game_build(&record.source) && seen_run_ids.insert(record.run_id.clone()) { ordered_run_ids.push(record.run_id.clone()); @@ -14622,7 +14622,7 @@ pub(in crate::agent) fn ensure_autonomous_completion_contract_for_task_at( return Ok(()); } if task.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID - || !agent_runtime_supervisor_source_is_trusted(&task.source) + || !agent_runtime_supervisor_source_is_autonomous_game_build(&task.source) { return Err("自主构建完成合同只允许可信根 Supervisor Run".to_string()); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs index ffef15de5..f751ba781 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs @@ -9506,7 +9506,7 @@ fn plan_root_retry_identity_guard_precedes_delegated_and_autonomous_branches() { // ② autonomous 支:task.source 已损坏成非 plan,但 binding.source 是 plan。 // 顶部守卫按 task.source 判定,挡不住这一种,必须由 autonomous 支内的 // reject_supervisor_plan_autonomous_profile 兜住。 - let autonomous_binding = bind_game_creator_agent_runtime_run_profile_at( + let autonomous_error = bind_game_creator_agent_runtime_run_profile_at( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, "plan-retry-guard-autonomous", @@ -9514,21 +9514,20 @@ fn plan_root_retry_identity_guard_precedes_delegated_and_autonomous_branches() { Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), None, ) - .expect("bind plan+autonomous"); - let corrupted_source = failed_supervisor_task( - "plan-retry-guard-autonomous", - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, - AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, - &autonomous_binding.binding_fingerprint, - &session_id, - ); - let autonomous_error = - resolve_game_creator_agent_runtime_retry_configuration_at(&root, &corrupted_source, false) - .expect_err("autonomous 支不得取回 plan source 复活被禁组合"); + .expect_err("durable binding 不得写入 plan+autonomous 非法组合"); assert!( autonomous_error.contains(AGENT_RUNTIME_PLAN_AUTONOMOUS_PROFILE_UNSUPPORTED_KIND), "{autonomous_error}" ); + assert!( + !game_creator_agent_runtime_run_profile_binding_path( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "plan-retry-guard-autonomous", + ) + .exists(), + "非法 binding 不得落盘" + ); // 对照:合法 plan 根 run 不受本守卫影响,仍然保源。 let healthy = failed_supervisor_task( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs index 88c29f12c..1fa86435f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs @@ -178,6 +178,14 @@ pub(crate) fn create_plan_gdd_approval_pending_at( root: &Path, gdd: &PlanGddV1, ) -> Result<(), PlanningStorageError> { + if !crate::config::game_creator_planning_capability_enabled() + .map_err(|error| approval_error("PLAN_CAPABILITY_DISABLED", error))? + { + return Err(approval_error( + "PLAN_CAPABILITY_DISABLED", + "立项策划能力当前已停用", + )); + } let _lock = acquire_project_write_lock(root, "planning.approval-pending.create") .map_err(|error| approval_error("PLAN_DURABILITY_FAILED", error))?; create_plan_gdd_approval_pending_locked(root, gdd) @@ -299,6 +307,11 @@ pub(crate) fn ensure_plan_gdd_approval_pending_after_acceptance_locked( agent_id: &str, run_id: &str, ) -> Result { + if !crate::config::game_creator_planning_capability_enabled()? { + // 停用是 rollout 状态而非 authority 损坏:该自动门不应把既有根 Run + // 推入 needs-reconciliation,更不能借恢复路径写入新的 pending。 + return Ok(PlanGddAcceptanceGateOutcome::NotApplicable); + } if agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID || run_id.trim().is_empty() { return Ok(PlanGddAcceptanceGateOutcome::NotApplicable); } @@ -501,6 +514,9 @@ pub(crate) fn ensure_plan_gdd_approval_pending_after_acceptance_at( agent_id: &str, run_id: &str, ) -> Result { + if !crate::config::game_creator_planning_capability_enabled()? { + return Ok(PlanGddAcceptanceGateOutcome::NotApplicable); + } if agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID || run_id.trim().is_empty() { return Ok(PlanGddAcceptanceGateOutcome::NotApplicable); } @@ -1081,6 +1097,11 @@ fn project_receipt_locked( pub(crate) fn reconcile_plan_gdd_approval_projections_at( root: &Path, ) -> Result { + if !crate::config::game_creator_planning_capability_enabled() + .map_err(|error| approval_error("PLAN_CAPABILITY_DISABLED", error))? + { + return Ok(false); + } let _lock = acquire_project_write_lock(root, "planning.approval-recovery").map_err(|error| { approval_error( @@ -1088,6 +1109,12 @@ pub(crate) fn reconcile_plan_gdd_approval_projections_at( redact_agent_runtime_project_paths(root, &error, 500), ) })?; + reconcile_plan_gdd_approval_projections_locked(root) +} + +pub(crate) fn reconcile_plan_gdd_approval_projections_locked( + root: &Path, +) -> Result { let gdds = read_plan_gdd_chain_locked(root)?; let plan_root_run_ids = gdds .iter() @@ -1184,6 +1211,14 @@ fn validate_active_plan_root_for_decision_locked( "审批 GDD 不再绑定当前唯一 active plan 根 Run", )); } + if active_roots[0].status == "needs-reconciliation" + || active_roots[0].phase == "needs-reconciliation" + { + return Err(approval_error( + "PLAN_STALE_APPROVAL", + "审批 GDD 前必须先完成 plan 根 Run 的人工核对", + )); + } if active_roots[0].run_profile_binding_fingerprint != root_binding.binding_fingerprint { return Err(approval_error( "PLAN_SOURCE_PROFILE_MISMATCH", @@ -1197,6 +1232,14 @@ pub(crate) fn decide_plan_gdd_at( root: &Path, input: &DecidePlanGddInputV1, ) -> Result { + if !crate::config::game_creator_planning_capability_enabled() + .map_err(|error| approval_error("PLAN_CAPABILITY_DISABLED", error))? + { + return Err(approval_error( + "PLAN_CAPABILITY_DISABLED", + "立项策划能力当前已停用", + )); + } validate_decision_transport(input)?; let _lock = acquire_project_write_lock(root, "planning.gdd-decision").map_err(|error| { approval_error( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_hydrate.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_hydrate.rs index 02d09ebc2..257c4fb25 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_hydrate.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_hydrate.rs @@ -411,6 +411,48 @@ fn build_state_view_locked( }) } +fn validate_hydrate_project_identity( + project_id: &str, + gdds: &[PlanGddV1], + approvals: &[PlanGddApprovalV1], + session: Option<&PlanSessionV1>, + pending: Option<&PlanGddApprovalPendingV1>, +) -> Result<(), PlanningStorageError> { + for gdd in gdds { + if gdd.project_id != project_id { + return Err(plan_gdd_state_error( + "PLAN_PROJECT_ID_MISMATCH", + "GDD projectId 与当前项目 manifest 不一致", + )); + } + } + for approval in approvals { + if approval.project_id != project_id { + return Err(plan_gdd_state_error( + "PLAN_PROJECT_ID_MISMATCH", + "approval receipt projectId 与当前项目 manifest 不一致", + )); + } + } + if let Some(session) = session { + if session.project_id != project_id { + return Err(plan_gdd_state_error( + "PLAN_PROJECT_ID_MISMATCH", + "planning session projectId 与当前项目 manifest 不一致", + )); + } + } + if let Some(pending) = pending { + if pending.project_id != project_id { + return Err(plan_gdd_state_error( + "PLAN_PROJECT_ID_MISMATCH", + "approval pending projectId 与当前项目 manifest 不一致", + )); + } + } + Ok(()) +} + pub(crate) fn hydrate_game_creator_plan_gdd_state_at( root: &Path, ) -> Result { @@ -424,32 +466,9 @@ pub(crate) fn hydrate_game_creator_plan_gdd_state_at( )); } - // 这里的顺序与方案 §18.3 的固定顺序相反,是刻意保留的现状,改动前先读完这段。 - // - // §18.3 要求身份校验(第 3 步)先于 session 前滚(第 5 步)与 index/pending 重建 - // (第 6 步)。本函数反过来:先 `reconcile`、再前滚 session、再重建 index,最后才在 - // `build_state_view_locked` 里比对 GDD/session 的 projectId 与 manifest。 - // - // 现在没有后果,是因为那道比对恒过——App 的全部 init/import 调用点都传同一个常量 - // `seedManifest.projectId`(`local-project-draft`),本机每个项目的 manifest 里写的 - // 都是它,所以 projectId 分辨不出任意两个项目。该常量不属本模块管辖,此处不改。 - // - // **一旦 projectId 改成每项目唯一,这道校验才真正开始工作,本顺序就必须一起改**: - // 否则 hydrate 会先对一棵属于别的项目的 planning 树做完投影修复,才发现认错了人。 - // - // 改的时候有个坑:**不能把 `reconcile` 直接挪到 hydrate 取锁之后**。它自己要取项目 - // 锁,而 `.agent/project.lock` 是 `create_new(true)` 的非重入文件锁,调用方已持锁再 - // 进去会死等满重试预算然后失败。可行的两条路是:在 `reconcile` 之前加一次锁外的 - // projectId 预检;或把 `reconcile` 拆成取锁薄壳 + `_at_locked` 核心,由 hydrate 在 - // 自己那把锁内调用(仓库已有同型拆法,见 `observe_agent_runtime_agent_delegate`)。 - // - // 直接传播:`PlanningStorageError` 的 Display 已是 `"{code}: {detail}"`, - // 再用同一个 code 把 `to_string()` 当 detail 重包一次,只会渲染出 - // `CODE: CODE: detail`,code 与 detail 都没有变化。 - let mut recovery_pending = reconcile_plan_gdd_approval_projections_at(root)?; - // `acquire_project_write_lock` 的 Err 内嵌 `.agent/project.lock` 的真实绝对路径, - // 而方案 §18.3 明令 hydrate 返回值「不包含绝对路径……或内部诊断」。任何后台写占着锁 - // 就会走到这里,是常态可达路径,必须先 redact 再进 typed error 的 detail。 + // §18.3:先在同一把项目锁内只读校验所有 authority 的 projectId,之后才允许 + // session/index/pending recovery 写入。这样复制到另一个项目的 sidecar 只能失败关闭, + // 不会在发现错绑前改写任何投影。 let _lock = crate::project::acquire_project_write_lock(root, "planning.hydrate").map_err(|error| { plan_gdd_state_error( @@ -459,6 +478,29 @@ pub(crate) fn hydrate_game_creator_plan_gdd_state_at( })?; let gdds = read_plan_gdd_chain_locked(root)?; let approvals = read_plan_gdd_approvals_locked(root)?; + let session_read_only = read_plan_session_read_only_locked(root)?; + let pending_read_only = read_plan_gdd_approval_pending_locked(root)?; + validate_hydrate_project_identity( + project_id, + &gdds, + &approvals, + session_read_only.as_ref(), + pending_read_only.as_ref(), + )?; + if !crate::config::game_creator_planning_capability_enabled() + .map_err(|error| plan_gdd_state_error("PLAN_CAPABILITY_DISABLED", error))? + { + return build_state_view_locked( + root, + project_id, + gdds, + approvals, + session_read_only, + pending_read_only, + false, + ); + } + let mut recovery_pending = reconcile_plan_gdd_approval_projections_locked(root)?; let session = read_plan_session_with_recovery_locked(root)?; let pending = if gdds.last().is_some_and(|latest| { approvals @@ -547,6 +589,92 @@ mod tests { assert!(!root.join(PLAN_STORAGE_ROOT).exists()); } + #[test] + fn hydrate_rejects_copied_planning_authority_before_repairing_projections() { + let temporary = tempfile::tempdir().expect("create hydrate fixture"); + let source = temporary.path().join("source"); + let target = temporary.path().join("target"); + crate::project::init_local_game_project_at(&source, "hydrate-source", "源项目") + .expect("initialize source project"); + crate::project::init_local_game_project_at(&target, "hydrate-target", "目标项目") + .expect("initialize target project"); + let mut source_session = PlanSessionV1 { + schema_version: PLAN_SESSION_SCHEMA_VERSION.to_string(), + project_id: "hydrate-source".to_string(), + gdd_id: "gdd-00000000-0000-4000-8000-000000000001".to_string(), + session_revision: 1, + previous_fingerprint: None, + session_fingerprint: format!("sha256-serde-json-v2:{}", "0".repeat(64)), + agent_id: GAME_CREATOR_PROJECT_PLANNING_AGENT_ID.to_string(), + source: "agent-delegate".to_string(), + run_profile: AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string(), + run_profile_binding_fingerprint: "a".repeat(64), + root_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + root_run_id: "run-hydrate-source".to_string(), + latest_delegation_id: "delegation-hydrate-source".to_string(), + session_id: "session-hydrate-source".to_string(), + active_run_id: None, + last_run_id: "run-hydrate-source".to_string(), + phase: "collecting".to_string(), + accumulated_agent_millis: 0, + applied_steer_cursor: 0, + decisions_summary: vec![PlanDecisionSummary { + id: "initial-request".to_string(), + topic: "初始需求".to_string(), + state: "confirmed".to_string(), + answer_source: "user_freeform".to_string(), + round: 0, + answer_summary: "做一款短局解谜游戏".to_string(), + }], + prototype_validation_items: Vec::new(), + applied_answers: Vec::new(), + latest_submitted_ref: None, + last_decision_ref: None, + updated_at_utc: "2026-08-19T00:00:00.000Z".to_string(), + }; + source_session.session_fingerprint = + plan_session_fingerprint(&source_session).expect("fingerprint source session"); + let source_planning = source.join(PLAN_STORAGE_ROOT); + std::fs::create_dir_all(&source_planning).expect("create source planning storage"); + std::fs::write( + source.join(PLAN_SESSION_PREVIOUS_PATH), + canonical_plan_session_bytes(&source_session).expect("canonical source session"), + ) + .expect("write source session recovery copy"); + let target_planning = target.join(PLAN_STORAGE_ROOT); + std::fs::create_dir_all(&target_planning).expect("create target planning storage"); + std::fs::copy( + source.join(PLAN_SESSION_PREVIOUS_PATH), + target.join(PLAN_SESSION_PREVIOUS_PATH), + ) + .expect("copy planning authority"); + std::fs::write(target_planning.join("index.json"), b"copied-index") + .expect("write copied index projection"); + std::fs::create_dir_all(target.join("game")).expect("create target game directory"); + std::fs::write(target.join("game/fast_gdd.md"), b"copied-markdown") + .expect("write copied markdown projection"); + let target_planning = target.join(PLAN_STORAGE_ROOT); + let before_index = + std::fs::read(target_planning.join("index.json")).expect("read copied index"); + let before_markdown = + std::fs::read(target.join("game/fast_gdd.md")).expect("read copied markdown"); + let error = hydrate_game_creator_plan_gdd_state_at(&target) + .expect_err("copied planning authority must fail project identity"); + assert_eq!(error.code(), "PLAN_PROJECT_ID_MISMATCH"); + assert_eq!( + std::fs::read(target_planning.join("index.json")).expect("re-read copied index"), + before_index, + ); + assert_eq!( + std::fs::read(target.join("game/fast_gdd.md")).expect("re-read copied markdown"), + before_markdown, + ); + assert!( + !target_planning.join(PLAN_SESSION_PATH).exists(), + "身份失败前不得把 previous session 提升为 primary" + ); + } + #[test] fn hydrate_redacts_project_paths_from_contended_project_lock_errors() { let temporary = tempfile::tempdir().expect("create hydrate lock fixture"); @@ -591,9 +719,9 @@ mod tests { rendered.contains("$PROJECT_ROOT"), "脱敏占位符应当保留,实际为 {rendered}" ); - // Display 已经是 `"{code}: {detail}"`,再用同一 code 重包会渲染成 `CODE: CODE: …`。 + // hydrate 现在先取自身项目锁;错误仍须保持单一 typed code,不能被二次包装。 assert_eq!( - rendered.matches("PLAN_DURABILITY_FAILED").count(), + rendered.matches("PLAN_STORAGE_IO").count(), 1, "typed 错误码不应重复拼接,实际为 {rendered}" ); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs index ad6074171..b3372a586 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs @@ -4767,6 +4767,29 @@ pub(crate) fn read_plan_session_with_recovery_locked( } } +/// Inspect a planning session without promoting or deleting either recovery +/// file. Hydrate uses this before project identity has been established: a +/// copied sidecar must never be "repaired" in the receiving project. +pub(crate) fn read_plan_session_read_only_locked( + root: &Path, +) -> Result, PlanningStorageError> { + let primary_path = resolve_planning_path(root, PLAN_SESSION_PATH)?; + let previous_path = resolve_planning_path(root, PLAN_SESSION_PREVIOUS_PATH)?; + let primary = read_optional_plan_session_file(root, &primary_path, "plan session primary")?; + let previous = read_optional_plan_session_file(root, &previous_path, "plan session previous")?; + match (primary, previous) { + (None, None) => Ok(None), + (Some(primary), None) => Ok(Some(primary)), + (Some(primary), Some(previous)) => { + if primary != previous { + validate_plan_session_successor(&previous, &primary)?; + } + Ok(Some(primary)) + } + (None, Some(previous)) => Ok(Some(previous)), + } +} + #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct PlanningRuntimeWriteIdentity<'a> { pub(crate) agent_id: &'a str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs index d97306edc..17864bb43 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs @@ -1754,6 +1754,14 @@ pub(crate) fn execute_plan_submit_gdd( context: &PlanSubmitGddRuntimeContext, input: &PlanSubmitGddInputV1, ) -> Result { + if !crate::config::game_creator_planning_capability_enabled() + .map_err(|error| PlanningStorageError::new("PLAN_CAPABILITY_DISABLED", error))? + { + return Err(PlanningStorageError::new( + "PLAN_CAPABILITY_DISABLED", + "立项策划能力当前已停用", + )); + } validate_runtime_context(context)?; validate_plan_submit_gdd_input(input) .map_err(|error| submit_error("PLAN_INVALID_REQUEST", error.to_string()))?; @@ -3056,6 +3064,19 @@ mod tests { cleanup_fixture(root); } + #[test] + fn planning_capability_disabled_rejects_new_gdd_without_writing_authority() { + let _config_guard = crate::tests::write_test_local_config( + r#"{"planning":{"capabilityEnabled":false}}"#.to_string(), + ); + let (root, context, input) = submit_fixture(); + let error = execute_plan_submit_gdd(&root, &context, &input) + .expect_err("disabled planning capability must reject submit"); + assert_eq!(error.code(), "PLAN_CAPABILITY_DISABLED"); + assert!(!root.join(".agent/planning/gdd.v1.json").exists()); + cleanup_fixture(root); + } + #[test] fn committed_submit_rebuilds_each_derived_projection_without_allocating_v2() { let (root, context, input) = submit_fixture(); @@ -3371,6 +3392,100 @@ mod tests { cleanup_fixture(root); } + #[test] + fn approval_rejects_reconciliation_root_without_creating_receipt() { + let (root, context, input) = submit_fixture(); + execute_plan_submit_gdd(&root, &context, &input).expect("submit v1"); + let gdd = read_plan_gdd_chain(&root) + .expect("read submitted GDD") + .pop() + .expect("GDD exists"); + create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending"); + + let task_path = + game_creator_agent_runtime_task_path(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID); + let mut root_task = latest_game_creator_agent_runtime_tasks( + read_all_game_creator_agent_runtime_tasks(&task_path).expect("read root tasks"), + ) + .into_iter() + .find(|task| task.run_id == context.root_run_id) + .expect("plan root task"); + root_task.status = "running".to_string(); + root_task.phase = "needs-reconciliation".to_string(); + root_task.updated_at = unix_timestamp(); + let root_runtime = agent_runtime_state_from_task_record(&root_task); + append_game_creator_agent_runtime_task(&root, &root_runtime) + .expect("append reconciled root"); + + let error = decide_plan_gdd_at( + &root, + &approval_input( + &gdd, + "approve", + "gdd-response-00000000-0000-4000-8000-000000000099", + None, + ), + ) + .expect_err("reconciliation root must not create an approval receipt"); + assert_eq!(error.code(), "PLAN_STALE_APPROVAL"); + assert!(!root.join(".agent/planning/approvals/v1.json").exists()); + cleanup_fixture(root); + } + + #[test] + fn planning_capability_disabled_rejects_decision_without_creating_receipt() { + let (root, context, input) = submit_fixture(); + execute_plan_submit_gdd(&root, &context, &input).expect("submit v1"); + let gdd = read_plan_gdd_chain(&root) + .expect("read submitted GDD") + .pop() + .expect("GDD exists"); + create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending"); + let _config_guard = crate::tests::write_test_local_config( + r#"{"planning":{"capabilityEnabled":false}}"#.to_string(), + ); + + let error = decide_plan_gdd_at( + &root, + &approval_input( + &gdd, + "approve", + "gdd-response-00000000-0000-4000-8000-000000000098", + None, + ), + ) + .expect_err("disabled planning capability must reject decision"); + assert_eq!(error.code(), "PLAN_CAPABILITY_DISABLED"); + assert!(!root.join(".agent/planning/approvals/v1.json").exists()); + cleanup_fixture(root); + } + + #[test] + fn planning_capability_disabled_keeps_existing_authority_read_only() { + let (root, _gdd, root_runtime) = acceptance_gate_fixture(true); + let _config_guard = crate::tests::write_test_local_config( + r#"{"planning":{"capabilityEnabled":false}}"#.to_string(), + ); + + let outcome = ensure_plan_gdd_approval_pending_after_acceptance_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &root_runtime.run_id, + ) + .expect("disabled acceptance recovery is a no-op"); + assert_eq!(outcome, PlanGddAcceptanceGateOutcome::NotApplicable); + assert!(read_plan_gdd_approval_pending(&root) + .expect("read pending") + .is_none()); + + let view = hydrate_game_creator_plan_gdd_state_at(&root) + .expect("disabled hydrate exposes an existing sidecar read-only"); + assert_eq!(view.state, "ready_for_approval"); + assert!(!view.recovery_pending); + assert!(!root.join(".agent/planning/approval.pending.json").exists()); + cleanup_fixture(root); + } + #[test] fn plan_gdd_completion_blocker_requires_pending_and_terminal_observation() { let (root, context, input) = submit_fixture(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs index dca6c2e36..b235e7361 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs @@ -99,6 +99,7 @@ pub(in crate::agent) fn validate_agent_runtime_run_profile_binding_record( { return Err("Agent Runtime Run Profile 身份指纹无效".to_string()); } + reject_supervisor_plan_autonomous_profile(&binding.source, &binding.profile)?; if binding.parent_agent_id.is_some() != binding.parent_run_id.is_some() { return Err("Agent Runtime Run Profile 绑定父 Run 身份不完整".to_string()); } @@ -111,7 +112,7 @@ pub(in crate::agent) fn validate_agent_runtime_run_profile_binding_record( } if binding.profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && (binding.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID - || !agent_runtime_supervisor_source_is_trusted(&binding.source)) + || !agent_runtime_supervisor_source_is_autonomous_game_build(&binding.source)) { return Err("自主构建 Run Profile 只允许可信 Supervisor 入口绑定".to_string()); } 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 29a7497a6..c816c4fa7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -812,6 +812,11 @@ pub(crate) fn start_game_creator_supervisor_runtime_task( return Err("Project Supervisor 提交 source 不受信任".to_string()); } reject_supervisor_plan_autonomous_profile(source, run_profile)?; + if agent_runtime_supervisor_source_is_plan(source) + && !crate::config::game_creator_planning_capability_enabled()? + { + return Err("PLAN_CAPABILITY_DISABLED: 立项策划能力当前已停用".to_string()); + } start_game_creator_supervisor_background_task_for_session_at( root, session_id.as_deref(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index 424174564..84f28a379 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -1438,9 +1438,18 @@ pub(crate) fn merge_game_creator_config_file( config.mcp_servers.insert(server_id, server); } } + if let Some(planning) = file_config.planning { + if let Some(capability_enabled) = planning.capability_enabled { + config.planning.capability_enabled = capability_enabled; + } + } Ok(()) } +pub(crate) fn game_creator_planning_capability_enabled() -> Result { + Ok(load_game_creator_app_config()?.planning.capability_enabled) +} + fn game_creator_config_backup_path(path: &Path) -> PathBuf { path.with_file_name(format!( ".{}.previous", 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 5bf03bf1c..79482de96 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -732,6 +732,14 @@ struct GameCreatorAppConfigFile { agent_llm: Option>, editor_api: Option, mcp_servers: Option>, + planning: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct GameCreatorPlanningConfigFile { + #[serde(skip_serializing_if = "Option::is_none")] + capability_enabled: Option, } #[derive(Clone, Debug, Default, Deserialize, Serialize)] @@ -783,6 +791,15 @@ struct GameCreatorAppConfig { editor_api: GameCreatorEditorApiConfig, #[serde(default)] mcp_servers: BTreeMap, + #[serde(default)] + planning: GameCreatorPlanningConfig, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct GameCreatorPlanningConfig { + #[serde(default = "default_game_creator_planning_capability_enabled")] + capability_enabled: bool, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -1244,6 +1261,11 @@ fn default_game_creator_llm_auto_compact_token_limit() -> u64 { fn default_game_creator_llm_tool_output_token_limit() -> u64 { DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT } + +fn default_game_creator_planning_capability_enabled() -> bool { + true +} + const DEFAULT_CANVAS_SYNC_API_BASE_URL: &str = "http://127.0.0.1:8082"; const DEFAULT_GAME_CREATOR_APP_CONFIG_JSON: &str = include_str!("../../game-creator.config.json"); const GAME_CREATOR_LLM_MAX_OUTPUT_TOKENS: u32 = 320000; @@ -1310,6 +1332,15 @@ impl Default for GameCreatorAppConfig { agent_llm: BTreeMap::new(), editor_api: GameCreatorEditorApiConfig::default(), mcp_servers: BTreeMap::new(), + planning: GameCreatorPlanningConfig::default(), + } + } +} + +impl Default for GameCreatorPlanningConfig { + fn default() -> Self { + Self { + capability_enabled: default_game_creator_planning_capability_enabled(), } } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs index 2b70787dc..f0a5d3ba2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs @@ -305,6 +305,14 @@ fn canonical_agent_reasoning_effort_defaults_are_exhaustive_and_auditable() { template.llm.as_ref().and_then(|llm| llm.max_retries), Some(DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES) ); + assert_eq!( + template + .planning + .as_ref() + .and_then(|planning| planning.capability_enabled), + Some(true), + "bundled runtime config must keep planning capability enabled by default" + ); assert!( template.agent_llm.unwrap_or_default().is_empty(), "bundled template must not persist canonical defaults as explicit overrides" @@ -575,6 +583,7 @@ fn app_config_commands_write_runtime_config_file() { }, agent_llm, mcp_servers: BTreeMap::new(), + planning: GameCreatorPlanningConfig::default(), }) .expect("write runtime config"); @@ -655,6 +664,7 @@ fn app_config_write_rejects_invalid_api_kind() { editor_api: GameCreatorEditorApiConfig::default(), agent_llm: BTreeMap::new(), mcp_servers: BTreeMap::new(), + planning: GameCreatorPlanningConfig::default(), }); assert!(result @@ -679,6 +689,7 @@ fn app_config_write_rejects_invalid_reasoning_effort() { editor_api: GameCreatorEditorApiConfig::default(), agent_llm: BTreeMap::new(), mcp_servers: BTreeMap::new(), + planning: GameCreatorPlanningConfig::default(), }); assert!(result @@ -703,6 +714,7 @@ fn app_config_write_rejects_too_small_request_timeout() { editor_api: GameCreatorEditorApiConfig::default(), agent_llm: BTreeMap::new(), mcp_servers: BTreeMap::new(), + planning: GameCreatorPlanningConfig::default(), }); assert!(result diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 733e966a3..e91bff851 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -32,6 +32,7 @@ import { AGENT_RUN_HISTORY_VISIBLE_STEP, CONVERSATION_INITIAL_VISIBLE_COUNT, CONVERSATION_VISIBLE_STEP, + createLocalProjectId, PROJECT_SUPERVISOR_AGENT_ID, PROJECT_SUPERVISOR_PLAN_SOURCE, seedManifest, @@ -3364,7 +3365,7 @@ export function App({ } : await invoke('init_local_game_project', { projectPath: trimmedProjectPath, - projectId: seedManifest.projectId, + projectId: createLocalProjectId(), name: gameChatOnly ? projectNameFromPath(trimmedProjectPath) : seedManifest.name, @@ -3497,7 +3498,7 @@ export function App({ if (directoryStatus.isGodotProject) { await invoke('import_local_godot_project', { projectPath: trimmedProjectPath, - projectId: seedManifest.projectId, + projectId: createLocalProjectId(), name: projectNameFromPath(trimmedProjectPath), }); await openWorkspace(trimmedProjectPath, false, 'open', 'godot'); diff --git a/apps/ai-game-creator-shell/src/app/constants.ts b/apps/ai-game-creator-shell/src/app/constants.ts index 85b154cc8..722d862fd 100644 --- a/apps/ai-game-creator-shell/src/app/constants.ts +++ b/apps/ai-game-creator-shell/src/app/constants.ts @@ -5,6 +5,10 @@ export const seedManifest = createGameCreationAppManifest( '未命名游戏原型', ); +export function createLocalProjectId(): string { + return `local-project-${crypto.randomUUID()}`; +} + export const AGENT_RUN_HISTORY_MAX_COUNT = 100; export const AGENT_RUN_HISTORY_INITIAL_VISIBLE_COUNT = 20; export const AGENT_RUN_HISTORY_VISIBLE_STEP = 20; diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 7dfc22ba4..7efd94a66 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -747,6 +747,9 @@ export interface GameCreatorAppConfig { apiKey: string; }; mcpServers: Record; + planning?: { + capabilityEnabled: boolean; + }; } export interface GameCreatorAppConfigView { diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts index 4ed070c6a..4896f9128 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts @@ -11,8 +11,8 @@ import type { GameCreationAppPreviewState, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { + createLocalProjectId, PROJECT_SUPERVISOR_PLAN_SOURCE, - seedManifest, } from '../../app/constants'; import { useEscapeToClose } from '../../app/dialogs'; import { resolveTauriInvoke } from '../../app/tauri'; @@ -266,7 +266,7 @@ export function useHomeProjectCreation({ 'init_local_game_project', { projectPath: trimmedProjectPath, - projectId: seedManifest.projectId, + projectId: createLocalProjectId(), name: projectNameFromPath(trimmedProjectPath), }, ); @@ -365,7 +365,7 @@ export function useHomeProjectCreation({ 'init_local_game_project', { projectPath: trimmedProjectPath, - projectId: seedManifest.projectId, + projectId: createLocalProjectId(), name: projectNameFromPath(trimmedProjectPath), }, ); @@ -444,7 +444,7 @@ export function useHomeProjectCreation({ 'import_local_godot_project', { projectPath: trimmedProjectPath, - projectId: seedManifest.projectId, + projectId: createLocalProjectId(), name: projectNameFromPath(trimmedProjectPath), }, ); 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 6d611b29a..fc5dccdaa 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -2246,7 +2246,7 @@ export function registerRecentProjectsTests() { await waitFor(() => { expect(invoke).toHaveBeenCalledWith('init_local_game_project', { projectPath: '/tmp/non-empty-game', - projectId: 'local-project-draft', + projectId: expect.stringMatching(/^local-project-/), name: 'non-empty-game', }); expect(screen.getByLabelText('项目总控对话')).not.toBeNull(); @@ -2289,7 +2289,7 @@ export function registerRecentProjectsTests() { await waitFor(() => { expect(invoke).toHaveBeenCalledWith('init_local_game_project', { projectPath: '/tmp/folder-named-game/', - projectId: 'local-project-draft', + projectId: expect.stringMatching(/^local-project-/), name: 'folder-named-game', }); }); diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts index 909885d16..b5719be6c 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts @@ -240,9 +240,7 @@ export function registerProjectCommandTests() { submitChat('/project /tmp/authorized-game'); fireEvent.click(screen.getByRole('button', { name: '确认' })); - expect( - await screen.findByText('已打开:authorized-game'), - ).not.toBeNull(); + expect(await screen.findByText('已打开:authorized-game')).not.toBeNull(); submitChat('/audit'); @@ -404,7 +402,7 @@ export function registerProjectCommandTests() { await waitFor(() => { expect(invoke).toHaveBeenCalledWith('init_local_game_project', { projectPath: '/tmp/main-non-empty-game', - projectId: 'local-project-draft', + projectId: expect.stringMatching(/^local-project-/), name: '未命名游戏原型', }); }); @@ -3447,9 +3445,7 @@ export function registerProjectCommandTests() { }); fireEvent.click(screen.getByRole('button', { name: '初始化' })); fireEvent.click(screen.getByRole('button', { name: '确认' })); - expect( - await screen.findByText('已打开:authorized-game'), - ).not.toBeNull(); + expect(await screen.findByText('已打开:authorized-game')).not.toBeNull(); fireEvent.click(logPanel.getByRole('button', { name: '命令日志' })); expect( diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 22bce6bfe..8f68d87ff 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -4709,7 +4709,7 @@ export function registerProjectSupervisorSurfaceTests() { await waitFor(() => { expect(invoke).toHaveBeenCalledWith('init_local_game_project', { projectPath, - projectId: 'local-project-draft', + projectId: expect.stringMatching(/^local-project-/), name: 'game-chat-non-empty', }); }); @@ -10504,7 +10504,7 @@ export function registerProjectSupervisorSurfaceTests() { const surface = await screen.findByLabelText('项目总控对话'); expect(invoke).toHaveBeenCalledWith('import_local_godot_project', { projectPath, - projectId: 'local-project-draft', + projectId: expect.stringMatching(/^local-project-/), name: 'existing-godot-project', }); expect( diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 17937d6ff..d7b9e7bd5 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,13 @@ # 决策记录 +## 2026-08-19 M1 审查后四项可靠性修复 + +- **审批 stale 收口**:`status` 或 `phase` 为 `needs-reconciliation` 的策划根不再满足审批所需的 active 身份;审批命令返回 `PLAN_STALE_APPROVAL`,并且不得创建 approval receipt。 +- **项目身份先验**:所有手动新建、首页新建、game-chat 新建与 Godot 导入都生成独立 `local-project-`;hydrate 在同一把项目锁内用只读 session 读取 GDD、receipt、pending、session 的 projectId,任一与 manifest 不符即返回 `PLAN_PROJECT_ID_MISMATCH`,不提升 session previous,也不改 index、Markdown 或 audit。 +- **rollout capability**:AppData 配置增加 `planning.capabilityEnabled`,默认 `true`。关闭后拒绝新的 plan 根 run、`plan.submit_gdd`、approval pending 和 decision mutation;hydrate 仅返回已有 sidecar 的只读视图,不执行恢复写入。 +- **profile/source 边界**:durable run-profile binding 集中拒绝 `project-supervisor-plan + autonomous-game-build`;自主构建的 scheduler 与 completion consumer 使用不含 plan 的精确 source matcher,普通 plan `standard` 仍属于通用 trusted matcher。 +- **边界**:不新增既有项目首次进入策划入口;不改做游戏、做素材路径。回归覆盖 capability、stale receipt、错项目 sidecar 写前失败、非法 binding 不落盘与新项目 ID 形状。 + ## 2026-08-19 更正 M1D-2 首页入口映射 - **更正**:`bf2185fba` 把“做游戏”误接为 `standard + project-supervisor-plan`,并额外增加“直接开建”按钮;这与既有产品决定“做方案入口独立成链,不动做游戏路径”冲突。 diff --git a/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md b/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md index 34c2fbc17..da1e5014b 100644 --- a/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md +++ b/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md @@ -1691,7 +1691,7 @@ Canvas 是条件外部能力,不是 M0-3 四个固定 owner 的通用前置: ## 20. rollout、停用与回滚 - M1 以 AppData/Runtime capability 开关启用,不把 feature flag 写进用户项目。 -- 开关关闭时不创建新 plan run、不接受新的 submit/decision mutation;已有 sidecar 只读保留,“直接开建”保持现状。 +- 开关字段为 AppData 配置中的 `planning.capabilityEnabled`,默认 `true`;关闭时不创建新 plan run、不接受新的 submit/decision mutation,也不执行 approval pending、session/index/Markdown/audit 的恢复写入。已有 sidecar 仅以只读 hydrate 视图保留,“直接开建”保持现状。 - 重新开启后从同一 gddId、版本、receipt 和 session chain 恢复,不重置版本。 - unknown/newer schema 只读失败,不由旧客户端降级覆盖。 - 任意冲突只前滚修复;禁止自动删除 planning 目录、重编号 GDD、改指纹、覆盖回执或抹去用户 comment。