策划 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:
@@ -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,
|
||||
|
||||
-12
@@ -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> {
|
||||
|
||||
@@ -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 / 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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user