From 41adfea13499621dcd6ae9349a632b6145ce66df Mon Sep 17 00:00:00 2001 From: Linghong Date: Mon, 24 Aug 2026 12:35:27 +0000 Subject: [PATCH] =?UTF-8?q?=E7=AD=96=E5=88=92=20hydrate=20=E6=94=B6?= =?UTF-8?q?=E7=AA=84=E5=88=B0=E7=AD=96=E5=88=92=E9=93=BE=E8=B7=AF=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E6=94=B6=E5=8F=A3=E4=B8=89=E5=A4=84=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E4=BA=8B=E5=AE=9E=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审查四条: 1. GDD hydrate 原本挂在任意 run 的任意一次监工状态变化上,而后端 hydrate 要抢项目写锁、扫 authority、必要时修投影,等于让做游戏和 做素材两条链路的每一拍心跳都去抢一次写锁。加一道门收窄。 门不能只看 source:审批卡的可见性判据是 `displayGdd && (pendingApproval || recoveryPending)`,跟当前 run 的 source 无关,非策划分支的监工面板也要靠 pendingApproval 点亮等待 审批位。所以判据是「策划链路 或 策划状态尚未落定」。 判据取 source 标量而不是 runtime 本体,保住那条 effect 依赖里只放 标量的原设计;策划状态读 ref 不进依赖,否则 hydrate 触发 hydrate。 后端把能力位读取提到取锁之前——它读的是应用配置不是项目文件,跟锁 无关,而 load_game_creator_app_config 每次都遍历所有配置路径读盘。 返回值不变,只是少占一段写锁。 2. 删掉零调用方的 append_plan_provider_usage_fact_for_test,cargo 的 never used 告警随之消失。 3. 前端 'project-supervisor-plan' 从三处收到 app/constants 一处: AgentRuntimeState.source 只是裸 string,改名没有任何编译期提示。 Rust 侧 filesystem.rs 两个守卫统一走 PLAN_FAST_GDD_PATH,顺带修掉 其中一处大小写敏感、另一处不敏感的不一致(两者串联使用,原先没有 实际绕过)。跨语言没有共享常量通道,TSX 与 mjs 只能留交叉引用注释。 4. App.tsx 里 game-chat 终态判定的两个本地实现删除,6 处调用改用 gameChatRuntimeProjection 的出口。保留 App.tsx 冻结 manifest 快照 的那道闸门——它判实时 manifest 决定要不要冻快照,与 canArchiveGameChatStage 判已冻快照不是重复检查。 验证:cargo check --all-targets 无新告警;agc:typecheck、eslint、 cargo fmt、prettier、check:encoding 通过;appSurface.test.ts 425/425。 Rust plan 过滤 404 passed / 2 failed,两条均已逐条定性为非回归: planning_clarification_answer_prepared_recovery_releases_execution_before_project_wait 在纯基线上同样失败(既存红),tool_plan_handoff_repair_restart_replays_chain_without_network_request 单独跑通过(本机批量 flaky)。 Co-Authored-By: Claude Opus 5 --- .../scripts/agent-swarm-test-chat.mjs | 3 ++ .../runtime_protocol/planning_hydrate.rs | 10 ++-- .../planning_provider_usage.rs | 12 ----- .../src-tauri/src/project/filesystem.rs | 5 +- apps/ai-game-creator-shell/src/App.tsx | 46 ++++++++-------- .../src/app/constants.ts | 10 +++- .../gameChatRuntimeProjection.ts | 18 +++++-- .../src/features/agent-runtime/model.ts | 10 ++-- .../project-workspace/GddApprovalCard.tsx | 9 +++- .../project-workspace/planningLane.ts | 54 +++++++++++++++++-- 10 files changed, 124 insertions(+), 53 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs b/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs index 62355787c..97d19625f 100644 --- a/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs @@ -1770,6 +1770,9 @@ export async function validateSwarmProjectArtifacts(projectPath, options) { return inspection; } +// 这四条路径的权威定义都在 Rust 侧 `planning_storage.rs`(`PLAN_SESSION_PATH`、 +// `PLAN_GDD_INDEX_PATH`、`PLAN_STORAGE_ROOT`、`PLAN_FAST_GDD_PATH`)。跨语言没有共享 +// 常量的通道,改路径时要连同 `GddApprovalCard.tsx` 一起动。 export const planningOutputPaths = [ '.agent/planning/session.json', '.agent/planning/index.json', 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 806ed1e6a..3d541f687 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 @@ -466,6 +466,12 @@ pub(crate) fn hydrate_game_creator_plan_gdd_state_at( )); } + // 能力位读的是应用配置,不是项目文件,跟项目锁没有任何关系。它必须在取锁之前算完: + // `load_game_creator_app_config` 每次都重新遍历所有配置路径读盘,把这段 IO 留在锁内 + // 会让每一次 hydrate 都多占一段写锁,而这条调用在监工每次状态变化时都会跑。 + let planning_capability_enabled = crate::config::game_creator_planning_capability_enabled() + .map_err(|error| plan_gdd_state_error("PLAN_CAPABILITY_DISABLED", error))?; + // §18.3:先在同一把项目锁内只读校验所有 authority 的 projectId,之后才允许 // session/index/pending recovery 写入。这样复制到另一个项目的 sidecar 只能失败关闭, // 不会在发现错绑前改写任何投影。 @@ -497,9 +503,7 @@ pub(crate) fn hydrate_game_creator_plan_gdd_state_at( 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))? - { + if !planning_capability_enabled { return build_state_view_locked( root, project_id, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_provider_usage.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_provider_usage.rs index c1dbeaa40..d421901a0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_provider_usage.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_provider_usage.rs @@ -500,18 +500,6 @@ pub(crate) fn fold_plan_provider_usage_before_new_request_at_locked( } } -#[cfg(test)] -pub(crate) fn append_plan_provider_usage_fact_for_test( - root: &Path, - fact: &PlanProviderUsageFactV1, -) -> Result { - validate_plan_provider_usage_fact_shape(fact)?; - append_agent_db_plan_provider_usage_idempotent( - root, - serde_json::to_value(fact).map_err(|error| error.to_string())?, - ) -} - #[cfg(test)] mod tests { use super::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs index ac8f60e2d..ab5253287 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs @@ -321,7 +321,8 @@ pub(crate) fn is_agent_planning_storage_path(normalized_path: &str) -> bool { } pub(crate) fn is_agent_planning_managed_write_path(normalized_path: &str) -> bool { - is_agent_planning_storage_path(normalized_path) || normalized_path == "game/fast_gdd.md" + is_agent_planning_storage_path(normalized_path) + || is_plan_fast_gdd_projection_path(normalized_path) } pub(crate) fn reject_agent_planning_storage_write_path( @@ -341,7 +342,7 @@ pub(crate) fn reject_agent_planning_storage_write_path( /// mutated by generic file tools. Keep this predicate write-only so planning /// observations can continue to read the projection. pub(crate) fn is_plan_fast_gdd_projection_path(normalized_path: &str) -> bool { - normalized_path.eq_ignore_ascii_case("game/fast_gdd.md") + normalized_path.eq_ignore_ascii_case(PLAN_FAST_GDD_PATH) } pub(crate) fn reject_plan_projection_write_path(normalized_path: &str) -> Result<(), String> { diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 4a53f901f..e985d68ce 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -114,6 +114,8 @@ import { gameChatRuntimeIdentity, isAgentFinalizationMessageId, isAgentRuntimeTerminalState, + isGameChatManifestPrimaryTaskTerminal, + isGameChatRuntimeTerminalState, isGameChatSupervisorRoot, isMissingAgentRuntimeResumeCommandError, isRuntimeConfigMissingError, @@ -231,6 +233,7 @@ import { parseRememberInput, } from './features/project-workspace/memoryCommands'; import { pendingCommandDetail } from './features/project-workspace/pendingCommandPresentation'; +import { planningStateNeedsRuntimeRefresh } from './features/project-workspace/planningLane'; import { isAgentTraceFilePath, isProjectPolicyConfirmableCommandId, @@ -421,28 +424,10 @@ function mergeGameChatRuntimeSnapshots( return Array.from(merged.values()); } -function gameChatManifestHasTerminalPrimaryTask( - manifest: GameCreationAppManifest | null, -) { - const tasks = - manifest?.tasks.filter((task) => task.id === 'code-prototype') ?? []; - return ( - tasks.length === 1 && - (tasks[0]?.status === 'completed' || tasks[0]?.status === 'failed') - ); -} - function gameChatStageRecordMessageId(runId: string) { return `game-chat-stage-record:${encodeURIComponent(runId)}`; } -function gameChatRuntimeHasTerminalOutcome(runtime: AgentRuntimeState) { - return ( - ['completed', 'failed', 'cancelled'].includes(runtime.status) || - ['completed', 'failed', 'cancelled'].includes(runtime.phase) - ); -} - function mergeGameChatHydratedConversationMessages( historyMessages: ChatMessage[], currentMessages: ChatMessage[], @@ -984,11 +969,24 @@ export function App({ ) { return; } + // 后端 hydrate 会抢项目写锁并扫 authority,不是纯内存读。没有这道门,做游戏和做 + // 素材链路的每一拍监工心跳都会去抢一次项目写锁——而那两条链路根本不产生策划状态。 + // 策划状态读 ref 而不进依赖:hydrate 成功就会换一个 `planGddState` 对象身份,写进 + // 依赖等于 hydrate 触发 hydrate。 + if ( + !planningStateNeedsRuntimeRefresh( + projectSupervisorRuntime?.source, + planGddStateRef.current, + ) + ) { + return; + } void hydratePlanGddState(localProject.projectPath); }, [ hydratePlanGddState, localProject?.projectPath, projectSupervisorRuntime?.phase, + projectSupervisorRuntime?.source, projectSupervisorRuntime?.status, projectSupervisorRuntime?.updatedAt, ]); @@ -1414,7 +1412,7 @@ export function App({ if ( pendingArchive && projectSupervisorRuntimeRef.current?.runId === runtime.runId && - gameChatManifestHasTerminalPrimaryTask(capturedManifest) + isGameChatManifestPrimaryTaskTerminal(capturedManifest) ) { pendingArchive.manifestSnapshot = capturedManifest; flushPendingGameChatStageRecords(); @@ -1476,7 +1474,7 @@ export function App({ if ( projectSupervisorRuntimeRef.current?.runId === pendingArchive.rootRuntime.runId && - gameChatManifestHasTerminalPrimaryTask(manifest) + isGameChatManifestPrimaryTaskTerminal(manifest) ) { pendingArchive.manifestSnapshot = manifest; } @@ -1661,7 +1659,7 @@ export function App({ if ( !gameChatOnly || !isGameChatSupervisorRoot(runtime) || - !gameChatRuntimeHasTerminalOutcome(runtime) + !isGameChatRuntimeTerminalState(runtime) ) { return; } @@ -1683,7 +1681,7 @@ export function App({ Object.values(agentRuntimeByIdRef.current), ), manifestSnapshot: - gameChatManifestHasTerminalPrimaryTask(manifest) && + isGameChatManifestPrimaryTaskTerminal(manifest) && projectSupervisorRuntimeRef.current?.runId === runtime.runId ? manifest : (previous?.manifestSnapshot ?? null), @@ -1705,7 +1703,7 @@ export function App({ gameChatOnly && nextProjectPath && runtime && - gameChatRuntimeHasTerminalOutcome(runtime) + isGameChatRuntimeTerminalState(runtime) ) { // Hydration can restore a terminal root run without delivering a live // runtime-update event. Feed that snapshot through the same deferred @@ -6459,7 +6457,7 @@ export function App({ 'get_local_game_manifest', { projectPath }, ); - if (!gameChatManifestHasTerminalPrimaryTask(capturedManifest)) { + if (!isGameChatManifestPrimaryTaskTerminal(capturedManifest)) { throw new Error('上一轮主阶段仍在收束,请稍后重试'); } pendingArchive.manifestSnapshot = capturedManifest; diff --git a/apps/ai-game-creator-shell/src/app/constants.ts b/apps/ai-game-creator-shell/src/app/constants.ts index 722d862fd..0a936c1fa 100644 --- a/apps/ai-game-creator-shell/src/app/constants.ts +++ b/apps/ai-game-creator-shell/src/app/constants.ts @@ -16,7 +16,15 @@ export const CONVERSATION_INITIAL_VISIBLE_COUNT = 20; export const CONVERSATION_VISIBLE_STEP = 20; export const AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD = 48; export const PROJECT_SUPERVISOR_AGENT_ID = 'project-supervisor'; -export const PROJECT_SUPERVISOR_PLAN_SOURCE = 'project-supervisor-plan'; +/** + * 立项策划链路的 run `source`。 + * + * 这是全前端唯一的字面量出处:`AgentRuntimeState.source` 在类型上只是 `string`, + * 改名不会有任何编译期提示,所以判据必须收敛到这一个常量上。`as const` 让它同时 + * 能充当 `ProjectSupervisorRuntimeSubmission['source']` 的成员。 + */ +export const PROJECT_SUPERVISOR_PLAN_SOURCE = + 'project-supervisor-plan' as const; export const launcherNotifications: Array<{ label: string; detail: string; diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/gameChatRuntimeProjection.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/gameChatRuntimeProjection.ts index 09e780c19..67681490b 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/gameChatRuntimeProjection.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/gameChatRuntimeProjection.ts @@ -246,6 +246,20 @@ function manifestPrimaryTaskStatus(manifest: GameCreationAppManifest | null) { return tasks.length === 1 ? tasks[0]!.status : null; } +/** + * 主任务是否已经落到终态。 + * + * 归档链路上有两处要问这个问题——先决定要不要把当前 manifest 冻成快照,再校验冻下来 + * 的那份快照能不能归档——两处必须是同一条判据,所以出口只留这一个。 + * 注意终态只有 completed / failed:manifest 任务没有 cancelled。 + */ +export function isGameChatManifestPrimaryTaskTerminal( + manifest: GameCreationAppManifest | null, +) { + const status = manifestPrimaryTaskStatus(manifest); + return status === 'completed' || status === 'failed'; +} + export function projectGameChatPrimaryProgress( manifest: GameCreationAppManifest | null, lineage: GameChatRuntimeLineage | null, @@ -454,7 +468,5 @@ export function canArchiveGameChatStage( ) { return false; } - return ['completed', 'failed'].includes( - manifestPrimaryTaskStatus(manifest) ?? '', - ); + return isGameChatManifestPrimaryTaskTerminal(manifest); } diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts index 600e7c6dc..2ab4f100f 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts @@ -2,7 +2,11 @@ import type { GameCreationAppManifest, GameCreationAppTaskState, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; -import { PROJECT_SUPERVISOR_AGENT_ID, seedManifest } from '../../app/constants'; +import { + PROJECT_SUPERVISOR_AGENT_ID, + PROJECT_SUPERVISOR_PLAN_SOURCE, + seedManifest, +} from '../../app/constants'; import type { AgentConversationSessionListResult, AgentConversationSessionRecord, @@ -37,7 +41,7 @@ export type ProjectSupervisorRuntimeSubmission = { source: | 'project-supervisor-gui' | 'project-supervisor-game-chat' - | 'project-supervisor-plan'; + | typeof PROJECT_SUPERVISOR_PLAN_SOURCE; }; export function resolveProjectSupervisorRuntimeSubmission({ @@ -58,7 +62,7 @@ export function resolveProjectSupervisorRuntimeSubmission({ if (planningEntry && !gameChatOnly && workspaceProjectKind === 'web') { return { runProfile: 'standard', - source: 'project-supervisor-plan', + source: PROJECT_SUPERVISOR_PLAN_SOURCE, }; } if ( diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx index 101678457..0d9681e01 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx @@ -6,7 +6,14 @@ import type { PlanGddStateViewV1, } from '../../app/types'; -/** 立项策划唯一的产品产物,由 `plan.submit_gdd` 与审批回执渲染到项目内。 */ +/** + * 立项策划唯一的产品产物,由 `plan.submit_gdd` 与审批回执渲染到项目内。 + * + * 权威定义在 Rust 侧 `planning_storage.rs` 的 `PLAN_FAST_GDD_PATH`,那里同时管着渲染 + * 落盘和写入守卫。跨语言没有共享常量的通道,这里只能重复一份;改路径时三处要一起动: + * `PLAN_FAST_GDD_PATH`、这里、以及 `scripts/agent-swarm-test-chat.mjs` 的 + * `planningOutputPaths`。 + */ const PLAN_FAST_GDD_RELATIVE_PATH = 'game/fast_gdd.md'; /** diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/planningLane.ts b/apps/ai-game-creator-shell/src/features/project-workspace/planningLane.ts index f069c02b5..f69dbf7cd 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/planningLane.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/planningLane.ts @@ -1,6 +1,5 @@ -import type { AgentRuntimeState } from '../../app/types'; - -export const PROJECT_SUPERVISOR_PLAN_SOURCE = 'project-supervisor-plan'; +import { PROJECT_SUPERVISOR_PLAN_SOURCE } from '../../app/constants'; +import type { AgentRuntimeState, PlanGddStateViewV1 } from '../../app/types'; /** * 当前总控 run 是否属于立项策划链路。 @@ -11,5 +10,52 @@ export const PROJECT_SUPERVISOR_PLAN_SOURCE = 'project-supervisor-plan'; export function isPlanningLaneRuntime( runtime: AgentRuntimeState | null | undefined, ) { - return runtime?.source === PROJECT_SUPERVISOR_PLAN_SOURCE; + return isPlanningLaneSource(runtime?.source); +} + +/** + * 同一个判据的标量入口。 + * + * `App.tsx` 里那条按监工状态重灌策划状态的 effect,依赖里只放 phase/status/updatedAt + * 这类标量——轮询每拍都会新建 runtime 对象,把本体写进依赖会让每一拍都重跑。要在那 + * 条 effect 里用上链路判据,就只能拿 `source` 这一个标量进去。 + */ +export function isPlanningLaneSource(source: string | null | undefined) { + return source === PROJECT_SUPERVISOR_PLAN_SOURCE; +} + +/** 策划状态里还会继续变的那几个态。`approved` / `rejected` 是终态。 */ +const PLAN_GDD_LIVE_STATES: ReadonlySet = new Set([ + 'draft', + 'ready_for_approval', + 'revision_requested', +]); + +/** + * 监工状态每次变动时,要不要重新 hydrate 策划状态。 + * + * hydrate 不是纯内存读:后端会抢项目写锁、扫 authority、必要时修投影。把它挂在 + * 「任意 run 的任意一次更新」上,等于让做游戏和做素材链路的每一拍心跳都去抢一次 + * 项目写锁。 + * + * 但也不能简单地只看 `isPlanningLaneRuntime`:审批卡的可见性判据是 + * `displayGdd && (pendingApproval || recoveryPending)`,跟当前 run 的 source 无关, + * 而非策划分支的监工面板还要靠 `planGddState.pendingApproval` 点亮等待审批位。所以 + * 策划状态自身还没落定时,即便当前 run 不是策划链路也必须继续跟。 + */ +export function planningStateNeedsRuntimeRefresh( + runtimeSource: string | null | undefined, + planGddState: PlanGddStateViewV1 | null | undefined, +) { + if (isPlanningLaneSource(runtimeSource)) { + return true; + } + if (!planGddState) { + return false; + } + return Boolean( + planGddState.pendingApproval || + planGddState.recoveryPending || + PLAN_GDD_LIVE_STATES.has(planGddState.state), + ); }