修复M1D审查发现的锁错误脱敏与澄清轮次口径

审批卡链路的三处项目锁错误改为脱敏后再进typed错误

阶段进度按等待状态显示真实轮次,不再透传0-indexed值

去掉reconcile错误的重复code拼接

补一条Rust脱敏回归与两条前端轮次回归

锁错误脱敏(方案 §18.3「返回值不包含绝对路径……或内部诊断」):
acquire_project_write_lock 的 Err 内嵌 .agent/project.lock 真实绝对路径。补
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,填 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 缺符号链接判定。复核后不
成立——read_manifest 自身在 is_symlink 处即拒,防护在另一层;.agent 目录本身
为符号链接的残差也无窗口,紧随其后的 resolve_planning_path 同样逐段判定。
未据此改动代码。

新记一条既有问题(非 M1D 引入):seedManifest.projectId 是常量
local-project-draft,App 的 5 个 init/import 调用点全传它,因此本机所有项目
projectId 相同。§18.3 第 1 步依赖的 projectId 校验因此分辨不出任意两个项目,
该门当前近乎恒真,须单独立项。

仍未修:hydrate 身份校验排在落盘投影修复之后(修它须注意 reconcile 自取项目
锁、.agent/project.lock 不可重入,不能把检查直接挪到 hydrate 取锁之后);
design 组展示名剩两处硬编码,且与 taskGroupLabels 命名体系不同,需先定口径。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 11:50:43 +00:00
parent abcc393fde
commit 7ca82ba7aa
6 changed files with 142 additions and 11 deletions
@@ -1081,8 +1081,13 @@ fn project_receipt_locked(
pub(crate) fn reconcile_plan_gdd_approval_projections_at(
root: &Path,
) -> Result<bool, PlanningStorageError> {
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<PlanGddDecisionResultV1, PlanningStorageError> {
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)?;
@@ -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}"
);
}
}
@@ -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 (
<section className="plan-gdd-stage-progress" aria-label="立项策划阶段进度">
<div className="plan-gdd-stage-progress__header">
@@ -47,7 +55,7 @@ export function PlanGddStageProgress({
<span>{stateLabels[state.state]}</span>
</div>
<div className="plan-gdd-stage-progress__meta">
<span>{`轮次 ${clarificationRound}/3`}</span>
<span>{roundLabel}</span>
<span>
{latestVersion === null
? '当前版本:草稿'
@@ -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());
@@ -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/**` 生产代码,审查结论不受影响。
@@ -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 或构建准入。