From abcc393fdee359315a45438309728bb84c1c6d2d Mon Sep 17 00:00:00 2001 From: Linghong Date: Tue, 18 Aug 2026 11:11:18 +0000 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DM1D=E5=AE=A1=E6=9F=A5?= =?UTF-8?q?=E5=8F=91=E7=8E=B0=E7=9A=84=E5=AE=A1=E6=89=B9=E5=86=B3=E5=AE=9A?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 决定失败也重灌权威状态,顺序钉死为先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 --- apps/ai-game-creator-shell/src/App.tsx | 39 +++- .../project-workspace/GddApprovalCard.tsx | 12 +- apps/ai-game-creator-shell/src/styles.css | 8 + .../tests/appSurface.test.ts | 2 + .../tests/appSurface/harness.ts | 182 ++++++++++++++++++ .../tests/appSurface/plan-gdd.suite.ts | 136 +++++++++++++ .../shared-memory/decision-log.md | 11 ++ ...方案】立项策划Agent(Fast GDD)-2026-08-10.md | 4 +- 8 files changed, 386 insertions(+), 8 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/appSurface/plan-gdd.suite.ts 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}