策划 hydrate 收窄到策划链路,并收口三处重复事实源

审查四条:

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 12:35:27 +00:00
parent 4c20e06a9a
commit 41adfea134
10 changed files with 124 additions and 53 deletions
@@ -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',
@@ -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,
@@ -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<bool, String> {
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::*;
@@ -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> {
+22 -24
View File
@@ -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;
@@ -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;
@@ -246,6 +246,20 @@ function manifestPrimaryTaskStatus(manifest: GameCreationAppManifest | null) {
return tasks.length === 1 ? tasks[0]!.status : null;
}
/**
* 主任务是否已经落到终态。
*
* 归档链路上有两处要问这个问题——先决定要不要把当前 manifest 冻成快照,再校验冻下来
* 的那份快照能不能归档——两处必须是同一条判据,所以出口只留这一个。
* 注意终态只有 completed / failedmanifest 任务没有 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);
}
@@ -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 (
@@ -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';
/**
@@ -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<PlanGddStateViewV1['state']> = 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),
);
}