diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs index d1fa67463..88c29f12c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs @@ -1081,8 +1081,13 @@ fn project_receipt_locked( pub(crate) fn reconcile_plan_gdd_approval_projections_at( root: &Path, ) -> Result { - let _lock = acquire_project_write_lock(root, "planning.approval-recovery") - .map_err(|error| approval_error("PLAN_DURABILITY_FAILED", error))?; + let _lock = + acquire_project_write_lock(root, "planning.approval-recovery").map_err(|error| { + approval_error( + "PLAN_DURABILITY_FAILED", + redact_agent_runtime_project_paths(root, &error, 500), + ) + })?; let gdds = read_plan_gdd_chain_locked(root)?; let plan_root_run_ids = gdds .iter() @@ -1193,8 +1198,12 @@ pub(crate) fn decide_plan_gdd_at( input: &DecidePlanGddInputV1, ) -> Result { validate_decision_transport(input)?; - let _lock = acquire_project_write_lock(root, "planning.gdd-decision") - .map_err(|error| approval_error("PLAN_DURABILITY_FAILED", error))?; + let _lock = acquire_project_write_lock(root, "planning.gdd-decision").map_err(|error| { + approval_error( + "PLAN_DURABILITY_FAILED", + redact_agent_runtime_project_paths(root, &error, 500), + ) + })?; let project_id = game_creator_agent_runtime_context_project_id(root) .map_err(|error| approval_error("PLAN_PROJECT_ID_MISMATCH", error))?; let gdds = read_plan_gdd_chain_locked(root)?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_hydrate.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_hydrate.rs index 1c5caf85c..f97731c67 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_hydrate.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_hydrate.rs @@ -424,10 +424,20 @@ pub(crate) fn hydrate_game_creator_plan_gdd_state_at( )); } - let mut recovery_pending = reconcile_plan_gdd_approval_projections_at(root) - .map_err(|error| plan_gdd_state_error(error.code(), error.to_string()))?; - let _lock = crate::project::acquire_project_write_lock(root, "planning.hydrate") - .map_err(|error| plan_gdd_state_error("PLAN_STORAGE_IO", error))?; + // 直接传播:`PlanningStorageError` 的 Display 已是 `"{code}: {detail}"`, + // 再用同一个 code 把 `to_string()` 当 detail 重包一次,只会渲染出 + // `CODE: CODE: detail`,code 与 detail 都没有变化。 + let mut recovery_pending = reconcile_plan_gdd_approval_projections_at(root)?; + // `acquire_project_write_lock` 的 Err 内嵌 `.agent/project.lock` 的真实绝对路径, + // 而方案 §18.3 明令 hydrate 返回值「不包含绝对路径……或内部诊断」。任何后台写占着锁 + // 就会走到这里,是常态可达路径,必须先 redact 再进 typed error 的 detail。 + let _lock = + crate::project::acquire_project_write_lock(root, "planning.hydrate").map_err(|error| { + plan_gdd_state_error( + "PLAN_STORAGE_IO", + redact_agent_runtime_project_paths(root, &error, 500), + ) + })?; let gdds = read_plan_gdd_chain_locked(root)?; let approvals = read_plan_gdd_approvals_locked(root)?; let session = read_plan_session_with_recovery_locked(root)?; @@ -517,4 +527,56 @@ mod tests { assert!(!view.recovery_pending); assert!(!root.join(PLAN_STORAGE_ROOT).exists()); } + + #[test] + fn hydrate_redacts_project_paths_from_contended_project_lock_errors() { + let temporary = tempfile::tempdir().expect("create hydrate lock fixture"); + let root = temporary.path().join("project"); + crate::project::init_local_game_project_at(&root, "hydrate-lock", "锁竞争项目") + .expect("initialize hydrate lock fixture"); + + // `.agent/project.lock` 是 `create_new(true)` 的文件锁且这一支不重试,预先占住它, + // hydrate 的第一次取锁(`reconcile` 内那次)就确定性失败。 + // + // 两个字段都必须写真值,否则会被失效锁回收顺手删掉、锁根本占不住: + // `pid` 供 unix 侧判 owner 是否存活;`createdAt` 供年龄判定—— + // `project_write_lock_age_seconds` 优先读这个 JSON 字段而**不是**文件 mtime, + // 填 0 会让锁显得有约 1.7e9 秒那么老,直接越过 600 秒的失效阈值。 + let lock_path = root.join(".agent/project.lock"); + let created_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock before unix epoch") + .as_secs(); + let held = serde_json::json!({ + "commandId": "test.hold", + "pid": std::process::id(), + "createdAt": created_at, + "nonce": 0, + }); + std::fs::write( + &lock_path, + serde_json::to_vec(&held).expect("serialize held lock"), + ) + .expect("hold project lock"); + + let error = hydrate_game_creator_plan_gdd_state_at(&root) + .expect_err("被占用的项目锁必须让 hydrate 失败"); + let rendered = error.to_string(); + + // 方案 §18.3:返回值不包含绝对路径或内部诊断。 + assert!( + !rendered.contains(&root.display().to_string()), + "hydrate 错误不得回传项目绝对路径,实际为 {rendered}" + ); + assert!( + rendered.contains("$PROJECT_ROOT"), + "脱敏占位符应当保留,实际为 {rendered}" + ); + // Display 已经是 `"{code}: {detail}"`,再用同一 code 重包会渲染成 `CODE: CODE: …`。 + assert_eq!( + rendered.matches("PLAN_DURABILITY_FAILED").count(), + 1, + "typed 错误码不应重复拼接,实际为 {rendered}" + ); + } } 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 71be7be2f..12c298131 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 @@ -39,7 +39,15 @@ export function PlanGddStageProgress({ state.displayGdd?.version ?? state.versions[state.versions.length - 1]?.gddRef.version ?? null; - const clarificationRound = state.session?.clarificationRound ?? 0; + const answeredRounds = state.session?.clarificationRound ?? 0; + // `clarificationRound` 是 0-indexed 的「已答轮数」:`static_delegate_lineage_counters` + // 排除目标自身、只数祖先里的澄清跳数,后端判上限用的也是 `current_round + 1`。所以直接 + // 按「轮次 X/3」渲染会整体差一格——问最后一轮时显示「轮次 2/3」,字面暗示还剩一轮。 + // 等待回答时 `latestDelegationId` 就是当前那条 delivery,+1 恰好是正在问的轮次;其余 + // 状态(含 `awaitingAnswerFor` 为 null 的恢复态)退回「已完成」表述,不去猜当前轮。 + const roundLabel = state.session?.awaitingAnswerFor + ? `第 ${answeredRounds + 1} 轮 / 共 3 轮` + : `已完成 ${answeredRounds}/3 轮澄清`; return (
@@ -47,7 +55,7 @@ export function PlanGddStageProgress({ {stateLabels[state.state]}
- {`轮次 ${clarificationRound}/3`} + {roundLabel} {latestVersion === null ? '当前版本:草稿' diff --git a/apps/ai-game-creator-shell/tests/appSurface/plan-gdd.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/plan-gdd.suite.ts index 7f1f16f1f..f2f36ccfc 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/plan-gdd.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/plan-gdd.suite.ts @@ -101,6 +101,47 @@ export function registerPlanGddApprovalTests() { ); }); + it('labels the clarification round the user is actually on rather than the 0-indexed answered count', async () => { + const harness = createProjectSupervisorRuntimeHarness(); + const base = createPlanGddStateView(); + const session = base.session; + if (!session) { + throw new Error('fixture 应当带 session'); + } + // clarificationRound 是「已答轮数」(0-indexed):等待第 3 轮回答时它恒为 2。 + // 直接渲染成「轮次 2/3」会暗示还剩一轮,而这已经是硬上限的最后一轮。 + harness.setPlanGddState( + createPlanGddStateView({ + session: { + ...session, + phase: 'awaiting_user_input', + clarificationRound: 2, + awaitingAnswerFor: { + delegationId: 'delegation-0003', + requestId: 'request-0003', + questionId: 'question-0003', + round: 2, + }, + }, + }), + ); + await mountApprovalCard(harness); + + const progress = await screen.findByLabelText('立项策划阶段进度'); + expect(within(progress).getByText('第 3 轮 / 共 3 轮')).not.toBeNull(); + expect(within(progress).queryByText('轮次 2/3')).toBeNull(); + }); + + it('falls back to an answered-count label when no clarification answer is outstanding', async () => { + const harness = createProjectSupervisorRuntimeHarness(); + // awaitingAnswerFor 为 null(含恢复态)时不去猜当前是第几轮,只报已完成多少轮。 + harness.setPlanGddState(createPlanGddStateView()); + await mountApprovalCard(harness); + + const progress = await screen.findByLabelText('立项策划阶段进度'); + expect(within(progress).getByText('已完成 2/3 轮澄清')).not.toBeNull(); + }); + it('blocks the already-open comment dialog once recovery starts without discarding the typed reason', async () => { const harness = createProjectSupervisorRuntimeHarness(); harness.setPlanGddState(createPlanGddStateView()); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 60063fad3..0cc1b727a 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,16 @@ # 决策记录 +## 2026-08-18 M1D 审查修复补充:锁错误脱敏与澄清轮次口径 + +- **锁错误回传绝对路径**:`acquire_project_write_lock` 的 Err 内嵌 `.agent/project.lock` 真实绝对路径,违反第 18.3 节「返回值不包含绝对路径……或内部诊断」。补 `redact_agent_runtime_project_paths` 的三处是前端审批卡真正会显示的那条链:`reconcile_plan_gdd_approval_projections_at`(hydrate 在取自己的锁之前调它)、hydrate 自己的锁、`decide_plan_gdd_at`(其错误与 hydrate 的错误渲染在同一个错误区)。planning 另有 14 个取锁点沿用未脱敏写法,属 M1B/M1C 既有模式,本次不扩面。脱敏不破坏 `项目正在被其他写操作占用:` 前缀,`project_gates.rs` / `provider_recovery.rs` 两处按前缀分类的判据不受影响。 +- **澄清轮次差一格**:`clarificationRound` 与 `awaitingAnswerFor.round` 都由 `static_delegate_lineage_counters` 派生,该函数排除目标自身,是 0-indexed 的「已答轮数」;后端判上限用的是 `current_round + 1`。阶段进度原样渲染成「轮次 X/3」整体差一格,问最后一轮时显示「轮次 2/3」,字面暗示还剩一轮。**只改前端文案,不动 DTO 语义**:等待回答时显示「第 N+1 轮 / 共 3 轮」(此时 `latestDelegationId` 就是当前 delivery,+1 恰好等于后端校验用的轮次),其余状态退回「已完成 N/3 轮澄清」,不猜当前轮。 +- **顺带**:`planning_hydrate.rs` 里 `reconcile` 的错误原本用同一 code 把 `to_string()` 当 detail 重包一层,而 `PlanningStorageError` 的 Display 已是 `"{code}: {detail}"`,渲染出 `CODE: CODE: detail`;code 与 detail 均无变化,改为直接 `?` 传播,并把「不重复拼 code」钉进回归。 +- **测试陷阱(值得记)**:写「占住项目锁」的 fixture 时必须给锁 JSON 填**真实** `createdAt`。失效锁回收的年龄判定读的是该 JSON 字段而**不是**文件 mtime(`project_write_lock_age_seconds`),填 0 会让锁显得约 1.7e9 秒老、越过 600 秒阈值被当场回收删除,hydrate 反而成功。第一版 fixture 正是这样自证失败的。 +- **验证**:Rust `planning_` 组 **155 passed / 0 failed**(原 154 + 本次 1 条);`appSurface.test.ts` **383 passed / 0 failed**(378 原有 + 5 条新增);三条新回归均经变异验证,逆转对应修复即变红。`cargo fmt --check`、`agc:typecheck`、ESLint `--max-warnings 0`、`check:encoding`、`git diff --check` 通过。 +- **撤回一条此前的审查发现**:曾判定 hydrate 读 manifest 缺符号链接判定(因其走裸 `root.join` 而非 `resolve_local_project_path`)。复核后**不成立**:`read_manifest` 自身在 `metadata.file_type().is_symlink()` 处即拒(`manifest.rs`),防护在另一层;`.agent` 目录本身为符号链接的残差也无窗口,紧随其后的 `resolve_planning_path` 同样逐段判定。未据此改动代码。 +- **仍未修**:① hydrate 在校验 GDD/session 的 projectId 与 manifest 一致之前已执行落盘投影修复,违反第 18.3 节固定顺序(修它须注意 `reconcile` 自取项目锁、`.agent/project.lock` 不可重入,不能把检查直接挪到 hydrate 取锁之后);② design 组展示名仍有 `agentPresentation.ts` 的 `groupConfigs` 与 `view/project-development/index.tsx` 的 `summarizeAgent` 两处硬编码「策划 Agent」,注意该两处与 `taskGroupLabels` **命名体系不同**(「X Agent」对「X组」),直接替换会连带改掉另外五个分组名,需先定命名口径。 +- **新记一条既有问题(非 M1D 引入)**:`seedManifest.projectId` 是常量 `local-project-draft`,App 的 5 个 init/import 调用点全传它,因此**本机所有项目 projectId 相同**。第 18.3 节第 1 步依赖的「manifest 与 projectId 校验」因此分辨不出任意两个项目——把 A 项目的 `.agent/planning/**` 整体拷入 B 项目仍会通过。该门当前近乎恒真,须单独立项处置。 + ## 2026-08-18 M1D 审查修复:GDD 审批决定失败路径与恢复期弹层门控 - **审查范围与基线**:对 `14c00017c..bf2185fba` 的 M1D-1/M1D-2 全量改动做规格对照审查(技术方案第 13、18 节)。审查完成后分支又前进了 `4624fd795`(文档同步)与 `c6a08ef98`(前端文案回归断言修复)两条,二者都不改 `src/**` 生产代码,审查结论不受影响。 diff --git a/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md b/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md index d093469e7..69fef7401 100644 --- a/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md +++ b/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md @@ -2027,7 +2027,7 @@ M0 完成不表示完整策划闭环已经上线。`M1A-1`~`M1A-4`、`M1B-1` | `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-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、编码检查通过。其中锁错误回传绝对路径与阶段进度轮次差一格两条已于同日补修(见 decision-log 同日「M1D 审查修复补充」条);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 或构建准入。