修复M1D审查发现的审批决定失败路径
决定失败也重灌权威状态,顺序钉死为先hydrate后写错误 responseId复用键纳入comment,改写修改意见时换新ID 恢复期门控扩到已打开的评论弹层,保留已输入内容 补appSurface harness的策划command分发与三条变异验证过的回归 审查基线为 14c00017c..bf2185fba(技术方案第 13、18 节)。其后分支已前进到 c6a08ef98,4624fd795 与c6a08ef98都不动 src/**,审查结论不受影响。 三条缺陷: 1. decidePlanGdd 只在成功分支 hydrate。后端 decide_plan_gdd_at 有多条真实 PLAN_STALE_APPROVAL 分支(GDD 不在当前 lineage、identity 不符、版本被取代、 pending 丢失),命中后卡片停在已失效的 pending 身份上、三个决定按钮仍可点, 且 recoveryPending 永不翻真导致「重试恢复」入口不渲染,卡内没有恢复路径。 现按第 18.3 节在失败分支同样 hydrate——该句不区分成功与失败。两句顺序不能反: hydratePlanGddState 入口会 setPlanGddError(null),先写错误再 hydrate 会把错误 擦掉;回归钉死了这个顺序。 2. responseId 复用键为 approvalRequestId:action,不含 comment,违反第 13.2 节 「改变 action/comment 必须生成新 responseId」。在第 14 节承认的「receipt 已提交 但 response 丢失」构造下,改写修改意见后重提会带旧 ID,命中后端「同 responseId 的审批意图不一致」硬拒,改写后的原因永远落不了盘。现按 approvalRequestId 存 {action, comment, responseId} 全量意图。判据方向为宁可多换不可少换:多换的最坏 后果是 replayed 降级成 already-decided(都是 Ok,且 already-decided 正是第 18.2 节要求的刷新态),少换是硬错误。 3. 第 18.2 节「recoveryPending 时不允许提交决定」原来只作用于三个触发按钮,而弹层 是打开之后才可能被后台 hydrate 翻掉资格的,其提交按钮只看 busy 与非空。现在 submitComment 与该按钮都判 canDecide。刻意不自动关弹层,否则会丢掉用户已经写好 的修改意见。 测试:harness 新增 hydrate_game_creator_plan_gdd_state 与 decide_game_creator_plan_gdd 分发分支及 createPlanGddStateView fixture;未配置策划 状态时 hydrate 与接入前一样抛出,既有 378 条行为不变。新增 plan-gdd.suite.ts 三条 回归并逐条变异验证——逆转对应修复后三条各自以自己的断言变红;修复二的变异是部分 逆转(保留新 Map 结构、只删 comment 比对),因此钉住的是 comment 这一维本身。 验证:appSurface.test.ts 381 passed / 0 failed;agentTraceSummary 与 rememberCommand (另两个 import src/App 的用例文件)13 passed;agc:typecheck 通过;6 个改动文件 ESLint --max-warnings 0 通过;check:encoding 5409 文件通过;git diff --check 干净。 不改 Rust——三条全在前端,后端语义已经正确。 文档:更正第 23.8 节 M1D-1 行误引的合入提交(5b11a0530 是 ESLint 修正,落地是 0052a80da),并补 M1D 审查修复快照。同时更正既有记录里「Shell typecheck / appSurface 受仓库依赖缺失阻断」的说法——在原分支主工作树上两道门都干净,实际是bf2185fba改名 taskGroupLabels.design 后自己把 8 个用例文件断言改红,由c6a08ef98补修。 未并入的四条审查发现:hydrate 身份校验排在落盘投影修复之后(第 18.3 节固定顺序, session.previous.json 提升+删除不可逆);锁竞争错误回传项目绝对路径(第 18.3 节); design 组展示名剩两处字典未改(第 18.2 节);阶段进度轮次差一格。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -630,7 +630,16 @@ export function App({
|
||||
const [planGddHydrateBusy, setPlanGddHydrateBusy] = useState(false);
|
||||
const [planGddDecisionBusy, setPlanGddDecisionBusy] = useState(false);
|
||||
const [planGddError, setPlanGddError] = useState<string | null>(null);
|
||||
const planGddDecisionResponseIdsRef = useRef(new Map<string, string>());
|
||||
const planGddDecisionResponseIdsRef = useRef(
|
||||
new Map<
|
||||
string,
|
||||
{
|
||||
action: PlanGddDecisionAction;
|
||||
comment: string | null;
|
||||
responseId: string;
|
||||
}
|
||||
>(),
|
||||
);
|
||||
|
||||
const hydratePlanGddState = useCallback(
|
||||
async (nextProjectPath?: string) => {
|
||||
@@ -681,11 +690,25 @@ export function App({
|
||||
if (!pending || !current?.displayGdd || !invoke || !targetProjectPath) {
|
||||
throw new Error('当前没有可提交的 GDD 审批决定');
|
||||
}
|
||||
const responseKey = `${pending.approvalRequestId}:${action}`;
|
||||
// 方案 §13.2:busy、超时与网络重试复用同一 responseId,但用户改变 action 或
|
||||
// comment 后必须换新的。旧键只含 `approvalRequestId:action`,改写修改意见时会
|
||||
// 带着旧 responseId 提交,命中后端「同 responseId 的审批意图不一致」硬错误。
|
||||
// 判据方向是宁可多换不可少换:多换的最坏后果是 receipt 已存在时把 replayed 降级
|
||||
// 成 already-decided,两者都是 Ok;少换是硬错误。
|
||||
const previousDecision = planGddDecisionResponseIdsRef.current.get(
|
||||
pending.approvalRequestId,
|
||||
);
|
||||
const responseId =
|
||||
planGddDecisionResponseIdsRef.current.get(responseKey) ??
|
||||
`gdd-response-${crypto.randomUUID()}`;
|
||||
planGddDecisionResponseIdsRef.current.set(responseKey, responseId);
|
||||
previousDecision &&
|
||||
previousDecision.action === action &&
|
||||
previousDecision.comment === comment
|
||||
? previousDecision.responseId
|
||||
: `gdd-response-${crypto.randomUUID()}`;
|
||||
planGddDecisionResponseIdsRef.current.set(pending.approvalRequestId, {
|
||||
action,
|
||||
comment,
|
||||
responseId,
|
||||
});
|
||||
setPlanGddDecisionBusy(true);
|
||||
setPlanGddError(null);
|
||||
try {
|
||||
@@ -702,6 +725,12 @@ export function App({
|
||||
});
|
||||
await hydratePlanGddState(targetProjectPath);
|
||||
} catch (error) {
|
||||
// 方案 §18.3 要求 decision 返回后以 hydrate 对权威文件的重验为准,失败分支同样
|
||||
// 适用:不重灌就会让卡片停在已失效的 pending 身份上,三个决定按钮仍可点,且
|
||||
// `recoveryPending` 永远翻不成真、「重试恢复」入口不渲染,卡内没有出路。
|
||||
// 两句顺序不能反——`hydratePlanGddState` 入口会 `setPlanGddError(null)`,
|
||||
// 先写错误再 hydrate 等于把这条错误擦掉。它自身从不抛出,不需要再包一层。
|
||||
await hydratePlanGddState(targetProjectPath);
|
||||
setPlanGddError(String(error));
|
||||
throw error;
|
||||
} finally {
|
||||
|
||||
@@ -162,7 +162,10 @@ export function GddApprovalCard({
|
||||
);
|
||||
|
||||
const submitComment = () => {
|
||||
if (!commentAction || !comment.trim()) {
|
||||
// 方案 §18.2:`recoveryPending` 期间只允许重试同一 ID,不允许提交决定。触发按钮
|
||||
// 已经由 `canDecide` 门住,但弹层是打开后才可能被后台 hydrate 翻掉资格的,
|
||||
// 所以提交口要自己再判一次,不能只靠按钮 disabled。
|
||||
if (!canDecide || !commentAction || !comment.trim()) {
|
||||
return;
|
||||
}
|
||||
void onDecision(commentAction, comment.trim())
|
||||
@@ -282,6 +285,11 @@ export function GddApprovalCard({
|
||||
placeholder="请输入原因"
|
||||
onChange={(event) => setComment(event.currentTarget.value)}
|
||||
/>
|
||||
{!canDecide ? (
|
||||
<p className="gdd-approval-card__dialog-hint" role="status">
|
||||
审批状态正在恢复,暂时不能提交决定。已输入的内容会保留。
|
||||
</p>
|
||||
) : null}
|
||||
<div className="gdd-approval-card__dialog-actions">
|
||||
<button
|
||||
type="button"
|
||||
@@ -295,7 +303,7 @@ export function GddApprovalCard({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !comment.trim()}
|
||||
disabled={!canDecide || busy || !comment.trim()}
|
||||
onClick={submitComment}
|
||||
>
|
||||
{busy ? '提交中' : '提交决定'}
|
||||
|
||||
@@ -3504,6 +3504,14 @@ textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.gdd-approval-card__dialog-hint {
|
||||
padding: 9px 10px;
|
||||
border-radius: 7px;
|
||||
color: #854d0e;
|
||||
background: #fff7df;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
registerHomeProjectCreationTests,
|
||||
registerRecentProjectsTests,
|
||||
} from './appSurface/home.suite';
|
||||
import { registerPlanGddApprovalTests } from './appSurface/plan-gdd.suite';
|
||||
import {
|
||||
registerCanvasAssetTests,
|
||||
registerProjectAssetTests,
|
||||
@@ -54,4 +55,5 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
registerProjectAssetTests();
|
||||
registerAgentRuntimeCommandTests();
|
||||
registerCanvasAssetTests();
|
||||
registerPlanGddApprovalTests();
|
||||
});
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
import React from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { PlanGddStateViewV1 } from '../../src/app/types';
|
||||
|
||||
const nativeClipboardMock = vi.hoisted(() => ({
|
||||
text: '',
|
||||
}));
|
||||
@@ -301,6 +303,150 @@ function agentRuntimeUserInputRequest({
|
||||
};
|
||||
}
|
||||
|
||||
function createPlanGddStateView(
|
||||
overrides: Partial<PlanGddStateViewV1> = {},
|
||||
): PlanGddStateViewV1 {
|
||||
const gddRef = {
|
||||
gddId: 'gdd-plan-0001',
|
||||
version: 1,
|
||||
fingerprint: 'sha256-serde-json-v2:1111111111111111',
|
||||
};
|
||||
return {
|
||||
schemaVersion: 'plan-gdd-state-view.v1',
|
||||
projectId: 'local-project-draft',
|
||||
gddId: gddRef.gddId,
|
||||
state: 'ready_for_approval',
|
||||
session: {
|
||||
sessionId: 'plan-session-0001',
|
||||
sessionRevision: 3,
|
||||
sessionFingerprint: 'sha256-serde-json-v2:2222222222222222',
|
||||
phase: 'awaiting_gdd_approval',
|
||||
clarificationRound: 2,
|
||||
repairDepth: 0,
|
||||
accumulatedAgentMillis: 42_000,
|
||||
activeRunId: null,
|
||||
awaitingAnswerFor: null,
|
||||
decisionStateCounts: {
|
||||
confirmed: 2,
|
||||
defaultPending: 1,
|
||||
prototypePending: 0,
|
||||
},
|
||||
},
|
||||
versions: [
|
||||
{
|
||||
gddRef,
|
||||
status: 'ready_for_approval',
|
||||
approvalRequestId: 'gdd-approval-0001',
|
||||
createdAtUtc: '2026-08-18T00:00:00Z',
|
||||
decision: null,
|
||||
},
|
||||
],
|
||||
displayGdd: {
|
||||
schemaVersion: 'plan-gdd.v1',
|
||||
projectId: 'local-project-draft',
|
||||
gddId: gddRef.gddId,
|
||||
version: gddRef.version,
|
||||
submissionId: 'action-0123456789abcdef01234567',
|
||||
approvalRequestId: 'gdd-approval-0001',
|
||||
actionFingerprint: 'a'.repeat(64),
|
||||
agentId: 'project-planning',
|
||||
source: 'agent-delegate',
|
||||
runProfile: 'standard',
|
||||
runProfileBindingFingerprint: 'b'.repeat(64),
|
||||
rootAgentId: 'project-supervisor',
|
||||
rootRunId: 'run-plan-root-0001',
|
||||
delegationId: 'delegation-0001',
|
||||
sessionId: 'plan-session-0001',
|
||||
sourceSessionRevision: 2,
|
||||
sourceSessionFingerprint: 'sha256-serde-json-v2:3333333333333333',
|
||||
createdByRunId: 'run-plan-child-0001',
|
||||
createdAtUtc: '2026-08-18T00:00:00Z',
|
||||
fingerprint: gddRef.fingerprint,
|
||||
game: {
|
||||
title: '灯塔守夜人',
|
||||
oneLiner: '在潮汐涨落之间调度光束,护送迷航的船只回港。',
|
||||
genre: { primary: '策略', fusion: null },
|
||||
artStyle: {
|
||||
visualType: '像素',
|
||||
keywords: ['夜色', '海雾'],
|
||||
moodAndColor: '冷蓝为主,暖黄光束作为唯一高光。',
|
||||
mvpArtBoundary: '只做灯塔与三类船只的静帧。',
|
||||
},
|
||||
pillars: [
|
||||
{
|
||||
name: '光束调度',
|
||||
playerFeel: '在有限视野里做取舍。',
|
||||
mechanism: '每回合只能照亮一个扇区。',
|
||||
decisionState: 'confirmed',
|
||||
basis: null,
|
||||
},
|
||||
],
|
||||
coreLoop: ['观察潮汐', '分配光束', '结算返港'],
|
||||
targetUsers: {
|
||||
coreUsers: '喜欢短局策略的玩家',
|
||||
preferences: '偏好可预测的规则',
|
||||
sessionLength: '单局 5 分钟',
|
||||
referenceGames: ['灯塔物语'],
|
||||
},
|
||||
platformFacts: {
|
||||
runtime: 'web',
|
||||
viewports: ['desktop', 'mobile'],
|
||||
inputs: ['pointer'],
|
||||
preview: '本地预览',
|
||||
},
|
||||
mvpSystems: [
|
||||
{
|
||||
system: '潮汐时钟',
|
||||
minimalFunction: '固定三段潮汐循环。',
|
||||
whyRequired: '没有它就没有节奏压力。',
|
||||
verifyMethod: '观察一局内三段是否各触发一次。',
|
||||
decisionState: 'confirmed',
|
||||
basis: null,
|
||||
},
|
||||
],
|
||||
outOfScope: ['多人对战'],
|
||||
creatorTips: {
|
||||
doFirst: '先做潮汐时钟。',
|
||||
deferForNow: '暂缓天气系统。',
|
||||
howToVerify: '单局跑满三段潮汐。',
|
||||
expandWhen: '核心循环稳定后再加船种。',
|
||||
},
|
||||
},
|
||||
decisions: [
|
||||
{
|
||||
id: 'decision-0001',
|
||||
topic: '光束是否可分裂',
|
||||
state: 'confirmed',
|
||||
answerSource: 'user_option',
|
||||
round: 1,
|
||||
answerSummary: '不可分裂,保持取舍压力。',
|
||||
basis: null,
|
||||
},
|
||||
],
|
||||
prototypeValidationItems: [
|
||||
{
|
||||
id: 'proto-0001',
|
||||
question: '单扇区照明是否足够做出取舍?',
|
||||
microPrototype: '纸面推演三回合。',
|
||||
observation: '玩家是否出现犹豫。',
|
||||
passCriterion: '三回合内至少一次改变计划。',
|
||||
},
|
||||
],
|
||||
},
|
||||
pendingApproval: {
|
||||
gddRef,
|
||||
pendingActionId: 'action-0123456789abcdef01234567',
|
||||
actionFingerprint: 'a'.repeat(64),
|
||||
approvalRequestId: 'gdd-approval-0001',
|
||||
sessionId: 'plan-session-0001',
|
||||
runId: 'run-plan-root-0001',
|
||||
},
|
||||
approvedGddRef: null,
|
||||
recoveryPending: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createProjectSupervisorRuntimeHarness({
|
||||
projectPath = '/tmp/authorized-game',
|
||||
sessionId = 'supervisor-session-active',
|
||||
@@ -366,6 +512,11 @@ function createProjectSupervisorRuntimeHarness({
|
||||
let rejectRuntime: Record<string, unknown> | null = null;
|
||||
let answerRuntime: Record<string, unknown> | null = null;
|
||||
let answerFailuresRemaining = 0;
|
||||
// 默认不配置策划状态:hydrate 与接入本 harness 之前一样抛出,既有用例行为不变。
|
||||
let currentPlanGddState: PlanGddStateViewV1 | null = null;
|
||||
let planGddDecisionError: string | null = null;
|
||||
let planGddHydrateCount = 0;
|
||||
const planGddDecisionCalls: Array<Record<string, unknown>> = [];
|
||||
let runtimeUpdateHandler:
|
||||
| ((event: {
|
||||
payload: {
|
||||
@@ -642,6 +793,26 @@ function createProjectSupervisorRuntimeHarness({
|
||||
});
|
||||
return runtimeResult();
|
||||
}
|
||||
if (command === 'hydrate_game_creator_plan_gdd_state') {
|
||||
planGddHydrateCount += 1;
|
||||
if (!currentPlanGddState) {
|
||||
throw new Error('PLAN_STATE_NOT_CONFIGURED');
|
||||
}
|
||||
return currentPlanGddState;
|
||||
}
|
||||
if (command === 'decide_game_creator_plan_gdd') {
|
||||
planGddDecisionCalls.push({ ...(args ?? {}) });
|
||||
if (planGddDecisionError) {
|
||||
const failure = planGddDecisionError;
|
||||
planGddDecisionError = null;
|
||||
throw new Error(failure);
|
||||
}
|
||||
return {
|
||||
schemaVersion: 'plan-gdd-decision-result.v1',
|
||||
outcome: 'decided',
|
||||
recoveryPending: false,
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
},
|
||||
);
|
||||
@@ -696,6 +867,16 @@ function createProjectSupervisorRuntimeHarness({
|
||||
failNextAnswers(count = 1) {
|
||||
answerFailuresRemaining = count;
|
||||
},
|
||||
setPlanGddState(state: PlanGddStateViewV1 | null) {
|
||||
currentPlanGddState = state;
|
||||
},
|
||||
failNextPlanGddDecision(message: string) {
|
||||
planGddDecisionError = message;
|
||||
},
|
||||
planGddDecisionCalls,
|
||||
planGddHydrateCount() {
|
||||
return planGddHydrateCount;
|
||||
},
|
||||
appendSupervisorMessage(message: Record<string, unknown>) {
|
||||
currentSupervisorMessages.push(message);
|
||||
},
|
||||
@@ -822,6 +1003,7 @@ export {
|
||||
cleanup,
|
||||
createGameCreationAppManifest,
|
||||
createGameCreationAppSeedTasks,
|
||||
createPlanGddStateView,
|
||||
createProjectSupervisorRuntimeHarness,
|
||||
deriveAgentStatusCards,
|
||||
describe,
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
createPlanGddStateView,
|
||||
createProjectSupervisorRuntimeHarness,
|
||||
expect,
|
||||
fireEvent,
|
||||
it,
|
||||
openMainProject,
|
||||
renderAppAt,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from './harness';
|
||||
|
||||
async function mountApprovalCard(
|
||||
harness: ReturnType<typeof createProjectSupervisorRuntimeHarness>,
|
||||
) {
|
||||
window.__TAURI__ = {
|
||||
core: { invoke: harness.invoke },
|
||||
event: { listen: harness.listen },
|
||||
};
|
||||
renderAppAt('/');
|
||||
await openMainProject(harness.projectPath);
|
||||
return await screen.findByLabelText('GDD 审批卡');
|
||||
}
|
||||
|
||||
function openReviseDialog() {
|
||||
fireEvent.click(screen.getByRole('button', { name: '修改' }));
|
||||
return screen.getByRole('dialog');
|
||||
}
|
||||
|
||||
function typeComment(dialog: HTMLElement, text: string) {
|
||||
const textarea = within(dialog).getByPlaceholderText('请输入原因');
|
||||
fireEvent.change(textarea, { target: { value: text } });
|
||||
return textarea as HTMLTextAreaElement;
|
||||
}
|
||||
|
||||
export function registerPlanGddApprovalTests() {
|
||||
it('re-hydrates authority after a failed GDD decision and keeps the failure visible', async () => {
|
||||
const harness = createProjectSupervisorRuntimeHarness();
|
||||
harness.setPlanGddState(createPlanGddStateView());
|
||||
await mountApprovalCard(harness);
|
||||
|
||||
const hydrateCallsBeforeDecision = harness.planGddHydrateCount();
|
||||
// 决定失败后,hydrate 应当读到权威侧已经变成「投影恢复中」的事实。
|
||||
harness.setPlanGddState(createPlanGddStateView({ recoveryPending: true }));
|
||||
harness.failNextPlanGddDecision('PLAN_STALE_APPROVAL');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '批准 v1' }));
|
||||
|
||||
// 失败分支必须重灌权威状态,否则卡片会停在已失效的 pending 身份上。
|
||||
await waitFor(() => {
|
||||
expect(harness.planGddHydrateCount()).toBeGreaterThan(
|
||||
hydrateCallsBeforeDecision,
|
||||
);
|
||||
});
|
||||
// 重灌后「重试恢复」入口出现——这是 recoveryPending 下唯一被允许的动作。
|
||||
await screen.findByRole('button', { name: '重试恢复' });
|
||||
// 而且重灌不能把决定失败的原因擦掉:hydrate 入口会 setPlanGddError(null),
|
||||
// 两句顺序写反这条断言就红。
|
||||
expect(screen.getByRole('alert').textContent).toContain(
|
||||
'PLAN_STALE_APPROVAL',
|
||||
);
|
||||
});
|
||||
|
||||
it('reuses one responseId while the decision is unchanged and mints a new one once the comment changes', async () => {
|
||||
const harness = createProjectSupervisorRuntimeHarness();
|
||||
harness.setPlanGddState(createPlanGddStateView());
|
||||
await mountApprovalCard(harness);
|
||||
|
||||
const dialog = openReviseDialog();
|
||||
typeComment(dialog, '把核心循环压到三步');
|
||||
harness.failNextPlanGddDecision('PLAN_DURABILITY_FAILED');
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交决定' }));
|
||||
await waitFor(() => {
|
||||
expect(harness.planGddDecisionCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
// 原样重试:属于方案 §13.2 的 busy/超时/网络重试,必须复用同一 responseId。
|
||||
harness.failNextPlanGddDecision('PLAN_DURABILITY_FAILED');
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交决定' }));
|
||||
await waitFor(() => {
|
||||
expect(harness.planGddDecisionCalls).toHaveLength(2);
|
||||
});
|
||||
expect(harness.planGddDecisionCalls[1].responseId).toBe(
|
||||
harness.planGddDecisionCalls[0].responseId,
|
||||
);
|
||||
|
||||
// 改写修改意见:审批意图变了,必须换新的 responseId,否则后端会以
|
||||
// 「同 responseId 的审批意图不一致」硬拒,用户改写后的原因永远落不了盘。
|
||||
typeComment(dialog, '把核心循环压到两步,并去掉天气系统');
|
||||
harness.failNextPlanGddDecision('PLAN_DURABILITY_FAILED');
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交决定' }));
|
||||
await waitFor(() => {
|
||||
expect(harness.planGddDecisionCalls).toHaveLength(3);
|
||||
});
|
||||
expect(harness.planGddDecisionCalls[2].responseId).not.toBe(
|
||||
harness.planGddDecisionCalls[0].responseId,
|
||||
);
|
||||
expect(harness.planGddDecisionCalls[2].comment).toBe(
|
||||
'把核心循环压到两步,并去掉天气系统',
|
||||
);
|
||||
});
|
||||
|
||||
it('blocks the already-open comment dialog once recovery starts without discarding the typed reason', async () => {
|
||||
const harness = createProjectSupervisorRuntimeHarness();
|
||||
harness.setPlanGddState(createPlanGddStateView());
|
||||
await mountApprovalCard(harness);
|
||||
|
||||
const dialog = openReviseDialog();
|
||||
const textarea = typeComment(dialog, '战斗节奏太慢,请压缩到三个回合');
|
||||
expect(
|
||||
(
|
||||
within(dialog).getByRole('button', {
|
||||
name: '提交决定',
|
||||
}) as HTMLButtonElement
|
||||
).disabled,
|
||||
).toBe(false);
|
||||
|
||||
// 弹层是打开之后才被后台 hydrate 翻掉决定资格的:触发按钮的 disabled 管不到它。
|
||||
harness.setPlanGddState(createPlanGddStateView({ recoveryPending: true }));
|
||||
fireEvent.focus(window);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
(
|
||||
within(dialog).getByRole('button', {
|
||||
name: '提交决定',
|
||||
}) as HTMLButtonElement
|
||||
).disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
// 已经写好的原因不能被丢掉——所以是禁用加说明,不是自动关弹层。
|
||||
expect(textarea.value).toBe('战斗节奏太慢,请压缩到三个回合');
|
||||
expect(harness.planGddDecisionCalls).toHaveLength(0);
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,16 @@
|
||||
# 决策记录
|
||||
|
||||
## 2026-08-18 M1D 审查修复:GDD 审批决定失败路径与恢复期弹层门控
|
||||
|
||||
- **审查范围与基线**:对 `14c00017c..bf2185fba` 的 M1D-1/M1D-2 全量改动做规格对照审查(技术方案第 13、18 节)。审查完成后分支又前进了 `4624fd795`(文档同步)与 `c6a08ef98`(前端文案回归断言修复)两条,二者都不改 `src/**` 生产代码,审查结论不受影响。
|
||||
- **修复一:决定失败也必须重灌权威状态**。`decidePlanGdd` 原来只在成功分支 hydrate,`catch` 只写错误后 rethrow。后端 `decide_plan_gdd_at` 有多条真实 `PLAN_STALE_APPROVAL` 分支(GDD 已不在当前 lineage、identity 不符、版本被更新版本取代、pending 丢失或不一致),命中后卡片停在已失效的 pending 身份上、三个决定按钮仍可点,且 `recoveryPending` 永不翻真导致「重试恢复」入口不渲染,卡内没有任何恢复路径。现在失败分支同样 hydrate,落实第 18.3 节「approval decision 返回后调用 hydrate」(该句不区分成功与失败)。**两句顺序已被回归钉死**:`hydratePlanGddState` 入口会 `setPlanGddError(null)`,必须先 hydrate 再写决定错误,写反会把这条错误擦掉。
|
||||
- **修复二:responseId 复用键纳入 comment**。原键为 `approvalRequestId:action`,不含 comment,违反第 13.2 节「用户改变 action/comment 后必须生成新 responseId」。在第 14 节恢复矩阵承认的「receipt 已提交但 command response 丢失」构造下,用户改写修改意见后重提会带着旧 responseId,命中后端「同 responseId 的审批意图不一致」硬拒,改写后的原因永远落不了盘。现在键挂在 `approvalRequestId` 上并比对 `{action, comment}` 完整意图。**判据方向为宁可多换不可少换**:receipt 已存在时多换的最坏后果是 `replayed` 降级成 `already-decided`(两者都是 Ok,且 already-decided 正是第 18.2 节要求的刷新态),少换则是硬错误。
|
||||
- **修复三:`recoveryPending` 必须挡住已经打开的评论弹层**。第 18.2 节要求恢复期只允许重试同一 ID、不允许提交决定;原实现只把 `canDecide` 接到三个触发按钮上,而弹层是打开之后才可能被后台 hydrate 翻掉决定资格的,其「提交决定」按钮只看 `busy || !comment.trim()`,仍可提交。现在 `submitComment` 与该按钮都判 `canDecide`,并在弹层内说明原因。**刻意不自动关弹层**,否则会丢掉用户已经写好的修改意见。
|
||||
- **测试**:appSurface harness 新增 `hydrate_game_creator_plan_gdd_state` / `decide_game_creator_plan_gdd` 两个分发分支与 `createPlanGddStateView` fixture;未配置策划状态时 hydrate 与接入前一样抛出,既有用例行为不变。新增 `tests/appSurface/plan-gdd.suite.ts` 三条回归,并逐条做过变异验证——把对应修复单独逆转后三条各自以自己的断言变红(修复二的变异是**部分逆转**:保留新 Map 结构、只删掉 comment 比对,因此该用例钉住的是 comment 这一维本身而非那次重构)。
|
||||
- **验证**:`appSurface.test.ts` **381 passed / 0 failed**(378 既有 + 3 新增);`agentTraceSummary` 与 `rememberCommand`(另两个 import `src/App` 的用例文件)13 passed;`agc:typecheck` 通过;6 个改动/新增文件 ESLint `--max-warnings 0` 通过;`check:encoding` 5409 文件通过。不改 Rust——三条全在前端,后端语义已经正确。
|
||||
- **对既有记录的更正**:M1D-1 与 M1D-2 两条记录分别称「Shell TypeScript typecheck 仍被仓库既有依赖缺失阻断」「appSurface UI suite 受仓库现有缺失 Tauri plugin 依赖阻断,未把该基线失败归因于本包」,在原分支主工作树上都不成立:`agc:typecheck` 干净退出,appSurface 378 条全绿;两道门分别位于 CI 的 `check:native-shells`(且 typecheck 排在 cargo test 之前)与 Frontend tests 内,一直是活的。实际情况与记录相反——`bf2185fba` 改名 `taskGroupLabels.design` 后,appSurface 有 8 个用例文件的断言变红,随后由 `c6a08ef98` 修复;把该套件记为「基线阻断、不归因本包」正是让这条自带回归合入的原因。**隔离工作树的依赖缺失不能作为跳过门禁的依据,须回原工作树复跑后再下结论。**
|
||||
- **未修的审查发现(本次不并入,单列后续)**:① hydrate 在校验 GDD/session 的 projectId 与 manifest 一致之前,已执行 `reconcile_plan_gdd_approval_projections_at`、session previous 提升与 index 重建等落盘修复,违反第 18.3 节固定顺序,其中 `session.previous.json` 的提升+删除不可逆(触发需外部篡改 `.agent/`,App 自身流程造不出该分歧);② 项目写锁竞争时 `acquire_project_write_lock` 的错误原文内嵌项目绝对路径,被原样回传前端,违反第 18.3 节「返回值不包含绝对路径」,常态可达;③ design 组展示名只改了 `taskGroupLabels` 一本字典,`agentPresentation.ts` 的 `groupConfigs` 与 `view/project-development/index.tsx` 的 `summarizeAgent` 仍硬编码「策划 Agent」,与新阶段「立项策划」同屏共存,违反第 18.2 节;④ 阶段进度「轮次 X/3」直接透传 0-indexed 的 `clarificationRound` 未 +1(后端自己用的是 `current_round + 1`),最后一轮显示「轮次 2/3」,字面暗示还剩一轮。
|
||||
|
||||
## 2026-08-18 M1D-2 隔离工作树实现:入口分流与阶段进度
|
||||
|
||||
- **隔离范围**:在 `codex/genarrative-isolated`、基线 `5b11a0530` 上开工;只接入口分流、阶段进度和实际项目总控页面的现有审批卡挂载,不接 M2 `approvedGddRef` 构建绑定、完整构建按钮或 M1E 端到端故障注入。
|
||||
|
||||
@@ -2023,10 +2023,12 @@ M0 完成不表示完整策划闭环已经上线。`M1A-1`~`M1A-4`、`M1B-1`
|
||||
| `M1C-2a` | Goal Contract 接线:turn 1 冻结、固定验收图、审批前置门取证 | `M1C-1`、`M1A-3` | **当前隔离 worktree 已完成并通过本包门禁,尚未合回**:turn 1 的 request-scoped schema 与格式修复都只允许一个固定 `agent.goal_contract`;按项目变化的四项之外,`preferences=[]`、唯一验收节点及证据工具均冻结。Fast GDD evidence 只接受当前 Supervisor 根 run 对 `game/fast_gdd.md` 从第 1 行到 EOF 的同 hash 完整分页;无/旧证据先继续读取,显式 failed 才给原 delivery 的 `repairOfDelegationId`,passed 且 delivery 已认领才建 pending。pending/recovery/completion/finalization 均按同 identity 幂等,审批后 Markdown 改写不损坏 Graph。格式、Provider 强判据、M1C-2a、Acceptance Graph、planning submit/approval、finalization、all-targets、编码与 diff 门禁均通过;扩展 autonomous completion 整组的无关 game-chat 并行超时及精确复跑结果见 decision-log 同日条,不改该路径。不包含 `M1C-2b`、UI 或构建准入 |
|
||||
| `M1C-2b` | 澄清中转接线、轮次派生、预算注入 | `M1C-2a` | **实现与本包门禁已完成并已快进合回 `feat/five_min_design`**:首 child 的 revision 1 session、`NeedsUserInput → awaiting_user_input`、回答绑定后 continuation 的确定性 session 投影、审批后 `revise/reject` 用户修订谱系及 Provider 活跃时间 usage fact/fold 已接线;末次 `plan.submit_gdd` usage 在 receipt/session successor 落盘且 standalone/v4 anchors 精确消费后于同一项目锁内折叠,真实 receipt 回归证明累计值恰好推进一次,重复审批与 recovery reconcile 不二次推进。`planning_clarification_*` **13 passed / 0 failed**(M1C-2c 语义回归另见本包),另有 static deliveries 44、planning storage 13、planning submit 53、Provider usage 4、末次 usage receipt 1、barrier detail 3 条定向回归通过;格式、offline all-targets、编码与 diff 门禁通过。锁序承诺只适用于 **M1C-2b 新增的 planning 澄清写投影路径**;`main_loop` 既有通用 completion blocker 的 execution→project 路径不在本包。第 4 轮信封在正常路径不可达:`agent.delegate` 已在工具边界按血缘上限硬拒并返回 failed observation;coordinator 的超三轮 reconciliation 仅用于损坏血缘纵深防御。审批 UI、hydrate、构建准入和下游完整构建不在本包范围 |
|
||||
| `M1C-2c` | 决策卡 A/B 语义(第 23.9 节,2026-08-18 实现完成并合回):选项 → 台账映射改为 A/B 均 `confirmed/user_option`、信封 label 形状校验、planning role brief 与 Supervisor playbook/final-reply 文案(B 必须是真实岔路、第 3 项恒定且 description 须给出可执行验证方式、改口转述规则、提问纪律) | `M1C-2b` | **实现与门禁完成,已由 `6e4bd9703` 合回 `feat/five_min_design`**:Runtime 已实现 A/B/固定第三项校验、B 不再生成 `default_pending`、`answerSummary` 逐字保真;非法 C/缺项 fail-closed,A/B/自由填写回归已通过。`planning_clarification_*` 13、`project_planning` prompt 5、`planning_submit` 定向回归、prompt bundle、格式、编码、diff、offline all-targets 均通过;不含 M1D-1 前端、hydrate、构建准入或下游完整构建 |
|
||||
| `M1D-1` | 前端 hydrate 与 GDD 审批卡;决策卡按第 23.9 节实现(label 动态渲染、默认焦点 A、Other 槽不变) | `M1C-2b` | **已完成并合入 `feat/five_min_design`(`5b11a0530`)**:新增严格 `{projectPath}` hydrate command、`plan-gdd-state-view.v1` Rust read model、审批卡与独立 GDD 正文详情弹层;页面只消费 hydrate,决定 responseId 按审批请求/动作复用,`recoveryPending` 仅提供恢复重试;审批前置 pending 与错绑 session 继续 fail-closed。 |
|
||||
| `M1D-1` | 前端 hydrate 与 GDD 审批卡;决策卡按第 23.9 节实现(label 动态渲染、默认焦点 A、Other 槽不变) | `M1C-2b` | **已完成并合入 `feat/five_min_design`(落地 `0052a80da`,其后 ESLint 修正 `5b11a0530`)**:新增严格 `{projectPath}` hydrate command、`plan-gdd-state-view.v1` Rust read model、审批卡与独立 GDD 正文详情弹层;页面只消费 hydrate,决定 responseId 按审批请求/动作复用,`recoveryPending` 仅提供恢复重试;审批前置 pending 与错绑 session 继续 fail-closed。 |
|
||||
| `M1D-2` | 入口分流与阶段进度 | `M1D-1` | **已完成并以 `bf2185fba` 合入 `feat/five_min_design`**:游戏新项目默认 `standard + project-supervisor-plan`,显式“直接开建”保持 `autonomous-game-build`;阶段进度显示轮次 x/3、当前版本和状态徽章;实际项目总控页面挂载 hydrate/审批卡,并将 `project-planning` / 设计组展示名收口。未接 M2 approved-GDD 构建绑定或完整构建按钮。 |
|
||||
| `M1E` | 端到端与故障注入收口 | `M1D-2` | 第 21 节测试矩阵中跨层场景 |
|
||||
|
||||
**2026-08-18 M1D 审查修复快照**:对 `14c00017c..bf2185fba` 做规格对照审查后,修复三条决定链路缺陷并补齐回归。① `decidePlanGdd` 的失败分支原来不 hydrate,命中后端任一 `PLAN_STALE_APPROVAL` 分支后卡片会停在已失效的 pending 身份上、`recoveryPending` 永不翻真导致「重试恢复」入口不渲染,现已按第 18.3 节在失败分支同样重灌(顺序钉死:`hydratePlanGddState` 入口会清空错误,必须先 hydrate 再写决定错误)。② responseId 复用键原为 `approvalRequestId:action`,不含 comment,违反第 13.2 节「改变 action/comment 必须换新 responseId」,现改为比对 `{action, comment}` 完整意图,判据方向为宁可多换不可少换。③ 第 18.2 节「`recoveryPending` 时不允许提交决定」原来只作用于三个触发按钮,已打开的评论弹层仍可提交,现已同门控并保留用户已输入内容。回归位于 `tests/appSurface/plan-gdd.suite.ts`,三条均经变异验证(逆转对应修复即变红);`appSurface.test.ts` 381 passed,`agc:typecheck`、ESLint、编码检查通过。**本次不含 Rust 改动**;hydrate 身份校验与落盘投影修复的顺序、锁错误回传绝对路径、design 组展示名剩余两处字典、以及阶段进度轮次差一格四条审查发现单列后续,未并入。
|
||||
|
||||
**2026-08-14 `M1B-1` 合入验收快照**:已合入实现集中在 `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs`,并由 `runtime_protocol.rs` 注册。已具备 `plan-gdd.v1`、`plan-gdd-index.v1`、`plan-session.v1` 及 `plan.submit_gdd` input 的 strict serde 形状校验、文本/ID/时间/枚举边界、typed serde fingerprint、canonical JSON(重复键、BOM、尾空白、字段顺序)解析、GDD 连续版本链、session `revision + 1` / `previousFingerprint` 链、create-only durable writer、session 原子替换与受限 recovery,以及 `project-planning / agent-delegate / standard / project-supervisor` writer identity。通用 `file.write`、`file.patch`、`file.delete`、`project.patchset` 与 checkpoint restore 对 `.agent/planning/**` 和 `game/fast_gdd.md` 只挡写,planning 的 `file.read` / `file.list` 仍可读;M1B-1 合入时没有注册或执行 `plan.submit_gdd`,也没有实现 approval pending、receipt、UI 或构建准入。
|
||||
|
||||
已验证第 9.1 节 golden vector(canonical envelope 3857 bytes 与指纹 `sha256-serde-json-v2:a59856de7ef134cf2f49c4dedd2ba10ae4ab2340a9634d402eb792b6ee5458f0`)及 11 个定向 storage 测试;durable writer 的目标 schema 重解析与文件名/版本核对、index 与权威 GDD 对账、缺失/损坏/陈旧 index 的锁内链重建、`(requestId,responseId)` / decision/ref / active run/phase 等 session 约束、GDD 文件枚举/链读取及 primary 损坏/previous 提升/分叉 recovery 矩阵均已覆盖。M1B-1 尚无 approval receipt schema,因此多版本 `statusCache` 只表达无 receipt 的预审批投影;M1C-1 接入 receipt 后重建真实 approved/revise/reject/superseded 状态。`cargo check --offline`、`npm run check:encoding`、`git diff --check` 均通过;该包现已合入本分支,这一历史快照不把后续 `plan.submit_gdd`、审批闭环、UI 或 renderer 误报为 M1B-1 已实现。
|
||||
|
||||
Reference in New Issue
Block a user