diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index d9af07557..733e966a3 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -630,7 +630,16 @@ export function App({ const [planGddHydrateBusy, setPlanGddHydrateBusy] = useState(false); const [planGddDecisionBusy, setPlanGddDecisionBusy] = useState(false); const [planGddError, setPlanGddError] = useState(null); - const planGddDecisionResponseIdsRef = useRef(new Map()); + 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 { 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 784edc2fd..71be7be2f 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 @@ -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 ? ( +

+ 审批状态正在恢复,暂时不能提交决定。已输入的内容会保留。 +

+ ) : null}