完成M1E提交拒绝有界收束

为 Fast GDD 提交拒绝新增持久化次数上限与恢复收束。

将既有权威事实的超限和版本边界转入 reconciliation。

同步 M1E 技术方案与项目决策记录。
This commit is contained in:
2026-08-18 14:20:23 +00:00
parent d44f930d1f
commit e3d318bf45
6 changed files with 250 additions and 12 deletions
@@ -161,10 +161,39 @@ pub(super) fn game_creator_agent_final_reply_error_allows_fallback(error: &str)
}
fn plan_submit_error_is_business_rejection(error: &PlanningStorageError) -> bool {
matches!(
error.code(),
"PLAN_INVALID_REQUEST" | "PLAN_SIZE_LIMIT" | "PLAN_VERSION_LIMIT_REACHED"
)
matches!(error.code(), "PLAN_INVALID_REQUEST" | "PLAN_SIZE_LIMIT")
}
/// A malformed Fast GDD is useful feedback for the planning child, but it
/// must not let one run replay an ever-growing prompt forever. Keep this
/// counter on the durable Runtime state rather than only in the in-memory
/// continuation: a process restart is part of the failure chain we bound.
const PLAN_SUBMIT_GDD_BUSINESS_REJECTION_LIMIT: u32 = 5;
fn plan_submit_business_rejection_limit_reached(rejection_count: u32) -> bool {
rejection_count >= PLAN_SUBMIT_GDD_BUSINESS_REJECTION_LIMIT
}
fn next_plan_submit_business_rejection_count(current: u32) -> (u32, bool) {
let next = current.saturating_add(1);
(next, plan_submit_business_rejection_limit_reached(next))
}
fn finish_plan_submit_business_rejection_limit_at(
root: &Path,
runtime: &AgentRuntimeState,
) -> Result<AgentBackgroundTaskOutcome, String> {
let error = format!(
"Fast GDD 连续 {} 次未通过 Runtime 校验,已停止自动续跑;请检查最后一次拒绝 observation 后重新发起策划。",
PLAN_SUBMIT_GDD_BUSINESS_REJECTION_LIMIT
);
let failed = fail_game_creator_agent_runtime_turn_at(root, runtime.clone(), &error)?;
let _ = append_game_creator_agent_background_task_failed_audit(
root,
&failed,
AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_PLAN_SUBMIT_REJECTION_LIMIT,
);
Ok(AgentBackgroundTaskOutcome::Finished)
}
/// A strict submit payload rejection is a normal planning observation, not a
@@ -204,6 +233,9 @@ fn project_plan_submit_business_rejection_at(
summary: "Fast GDD 提交被 Runtime 拒绝,请根据 observation 修正后重新提交。".to_string(),
detail: Some(public_error),
};
let (rejection_count, exhausted) =
next_plan_submit_business_rejection_count(runtime.plan_submit_gdd_rejection_count);
runtime.plan_submit_gdd_rejection_count = rejection_count;
let mut rejected = pending.clone();
rejected.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string();
rejected.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_REJECTED.to_string();
@@ -221,6 +253,9 @@ fn project_plan_submit_business_rejection_at(
&rejected,
&observation,
)?;
if exhausted {
return finish_plan_submit_business_rejection_limit_at(root, runtime);
}
let continuation = continuation_for_game_creator_agent_runtime_steer(
runtime,
&AgentRuntimeToolPlan::default(),
@@ -454,6 +489,8 @@ pub(in crate::agent) fn prepare_game_chat_single_round_convergence_at(
}
const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_TOOL_PLAN: &str = "tool-plan-failed";
const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_PLAN_SUBMIT_REJECTION_LIMIT: &str =
"plan-submit-validation-retries-exhausted";
const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_BUDGET: &str = "loop-budget-exhausted";
const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_FINAL_REPLY: &str = "final-reply-failed";
const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_FINALIZATION: &str = "finalization-failed";
@@ -913,6 +950,23 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
let mut converged = false;
let mut context_stalled = continuation.context_stalled;
// `project_game_creator_agent_runtime_provider_batch_abort` persists the
// rejection counter together with the rejected observation before this
// terminal transition. A crash between those two durable steps must not
// turn the fifth rejection into a sixth Provider request after recovery.
if plan_submit_business_rejection_limit_reached(runtime.plan_submit_gdd_rejection_count) {
return match finish_plan_submit_business_rejection_limit_at(&root, &runtime) {
Ok(outcome) => outcome,
Err(error) => fail_game_creator_agent_background_context_at(
&root,
&agent_id,
&session_id,
runtime,
&format!("收束已耗尽的 Fast GDD 提交拒绝失败:{error}"),
),
};
}
if continuation.applied_steer_cursor < runtime.applied_steer_cursor {
return fail_game_creator_agent_background_context_at(
&root,
@@ -4329,4 +4383,49 @@ mod plan_gdd_blocker_projection_tests {
))
.is_none());
}
/// The fifth rejected Fast-GDD payload is recorded, then stops the run;
/// it must not schedule a sixth Provider turn after a restart or a long
/// series of invalid serializations.
#[test]
fn plan_submit_business_rejection_limit_stops_on_the_fifth_rejection() {
for current in 0..PLAN_SUBMIT_GDD_BUSINESS_REJECTION_LIMIT - 1 {
let (next, exhausted) = next_plan_submit_business_rejection_count(current);
assert_eq!(next, current + 1);
assert!(!exhausted);
}
assert_eq!(
next_plan_submit_business_rejection_count(PLAN_SUBMIT_GDD_BUSINESS_REJECTION_LIMIT - 1),
(PLAN_SUBMIT_GDD_BUSINESS_REJECTION_LIMIT, true)
);
}
#[test]
fn plan_submit_business_rejection_limit_stays_terminal_after_recovery() {
assert!(!plan_submit_business_rejection_limit_reached(
PLAN_SUBMIT_GDD_BUSINESS_REJECTION_LIMIT - 1
));
assert!(plan_submit_business_rejection_limit_reached(
PLAN_SUBMIT_GDD_BUSINESS_REJECTION_LIMIT
));
assert!(plan_submit_business_rejection_limit_reached(u32::MAX));
}
#[test]
fn plan_submit_version_limit_is_an_authority_boundary_not_provider_feedback() {
let version_limit = PlanningStorageError::new(
"PLAN_VERSION_LIMIT_REACHED",
"不能继续创建第 129 个 GDD 版本",
);
assert!(plan_submit_error_is_business_rejection(
&PlanningStorageError::new("PLAN_INVALID_REQUEST", "候选 GDD 缺少标题")
));
assert!(plan_submit_error_is_business_rejection(
&PlanningStorageError::new("PLAN_SIZE_LIMIT", "候选 GDD 超出大小上限")
));
assert!(
!plan_submit_error_is_business_rejection(&version_limit),
"版本上限由既有 lineage 决定,重试相同 Provider submit 不会改变它"
);
}
}
@@ -910,6 +910,27 @@ fn submit_error(code: &'static str, detail: impl Into<String>) -> PlanningStorag
PlanningStorageError::new(code, detail)
}
/// A limit derived from existing immutable authority is not feedback the
/// Provider can correct by submitting the same turn again. Preserve input-side
/// limits, but turn the durable-authority branch into reconciliation before it
/// reaches the main-loop retry classifier.
fn existing_planning_authority_error(
error: PlanningStorageError,
authority: &str,
) -> PlanningStorageError {
match error.code() {
"PLAN_SIZE_LIMIT" => submit_error(
"PLAN_NEEDS_RECONCILIATION",
format!("{authority} 读取超出大小上限,不能作为 Provider submit 重试处理"),
),
"PLAN_VERSION_LIMIT_REACHED" => submit_error(
"PLAN_NEEDS_RECONCILIATION",
format!("{authority} 已达版本上限,不能作为 Provider submit 重试处理"),
),
_ => error,
}
}
fn session_recovery_error(error: &PlanningStorageError) -> PlanningStorageError {
submit_error(
"PLAN_SESSION_RECOVERY_REQUIRED",
@@ -1745,8 +1766,10 @@ pub(crate) fn execute_plan_submit_gdd(
// commit point.
validate_durable_child_binding(root, context)?;
let chain = read_plan_gdd_chain_locked(root)?;
let approvals = read_plan_gdd_approvals_locked(root)?;
let chain = read_plan_gdd_chain_locked(root)
.map_err(|error| existing_planning_authority_error(error, "既有 GDD 权威事实"))?;
let approvals = read_plan_gdd_approvals_locked(root)
.map_err(|error| existing_planning_authority_error(error, "既有 GDD approval receipt"))?;
// Keep a session read error until after durable action identity replay is
// resolved. The GDD create is the commit point: if session projection was
// lost/corrupted after that point, a retry must still return the committed
@@ -1843,9 +1866,12 @@ pub(crate) fn execute_plan_submit_gdd(
.map(|latest| latest.version.saturating_add(1))
.unwrap_or(1);
if version > PLAN_MAX_VERSIONS {
return Err(submit_error(
"PLAN_VERSION_LIMIT_REACHED",
"不能继续创建第 129 个 GDD 版本",
return Err(existing_planning_authority_error(
submit_error(
"PLAN_VERSION_LIMIT_REACHED",
"不能继续创建第 129 个 GDD 版本",
),
"既有 GDD lineage",
));
}
let approval_request_id = context
@@ -3111,6 +3137,103 @@ mod tests {
cleanup_fixture(root);
}
#[test]
fn submit_routes_oversized_existing_gdd_to_reconciliation_not_provider_retry() {
let (root, context, input) = submit_fixture();
fs::write(
root.join(".agent/planning/gdd.v1.json"),
vec![b'x'; 256 * 1024 + 1],
)
.expect("seed oversized immutable GDD authority");
let error = execute_plan_submit_gdd(&root, &context, &input)
.expect_err("oversized existing authority must block new submit");
assert_eq!(error.code(), "PLAN_NEEDS_RECONCILIATION");
cleanup_fixture(root);
}
#[test]
fn submit_routes_exhausted_existing_lineage_to_reconciliation_not_provider_retry() {
let (root, context, input) = submit_fixture();
let approvals_root = root.join(PLAN_GDD_APPROVAL_DIR);
fs::create_dir_all(&approvals_root).expect("create immutable approval authority directory");
for version in 1..=PLAN_MAX_VERSIONS {
let mut gdd = build_plan_gdd_from_submit_input(
&input,
&context,
version,
&format!("gdd-approval-00000000-0000-4000-8000-{version:012x}"),
)
.expect("build fixture GDD version");
gdd.submission_id = format!("action-{version:024x}");
gdd.action_fingerprint = format!("{version:064x}");
gdd.fingerprint = plan_gdd_fingerprint(&gdd).expect("fixture GDD fingerprint");
fs::write(
root.join(format!("{PLAN_STORAGE_ROOT}/gdd.v{version}.json")),
canonical_plan_gdd_bytes(&gdd).expect("fixture GDD canonical bytes"),
)
.expect("write fixture GDD authority");
let decision_input = PlanGddApprovalDecisionInputV1 {
project_id: gdd.project_id.clone(),
gdd_id: gdd.gdd_id.clone(),
version,
fingerprint: gdd.fingerprint.clone(),
pending_action_id: gdd.submission_id.clone(),
action_fingerprint: gdd.action_fingerprint.clone(),
approval_request_id: gdd.approval_request_id.clone(),
response_id: format!("gdd-response-00000000-0000-4000-8000-{version:012x}"),
action: "approve".to_string(),
comment: None,
};
let decision_fingerprint = plan_gdd_approval_decision_fingerprint_for_identity(
&decision_input,
PLAN_GDD_APPROVAL_SOURCE,
&gdd.run_profile,
&gdd.run_profile_binding_fingerprint,
&gdd.session_id,
&gdd.created_by_run_id,
None,
)
.expect("fixture approval decision fingerprint");
let mut approval = PlanGddApprovalV1 {
schema_version: PLAN_GDD_APPROVAL_SCHEMA_VERSION.to_string(),
project_id: gdd.project_id.clone(),
gdd_id: gdd.gdd_id.clone(),
version,
fingerprint: gdd.fingerprint.clone(),
pending_action_id: gdd.submission_id.clone(),
action_fingerprint: gdd.action_fingerprint.clone(),
approval_request_id: gdd.approval_request_id.clone(),
response_id: decision_input.response_id,
decision_fingerprint,
source: PLAN_GDD_APPROVAL_SOURCE.to_string(),
run_profile: gdd.run_profile.clone(),
run_profile_binding_fingerprint: gdd.run_profile_binding_fingerprint.clone(),
session_id: gdd.session_id.clone(),
run_id: gdd.created_by_run_id.clone(),
action: "approve".to_string(),
comment: None,
decided_at_utc: gdd.created_at_utc.clone(),
receipt_fingerprint: String::new(),
};
approval.receipt_fingerprint = plan_gdd_approval_receipt_fingerprint(&approval)
.expect("fixture receipt fingerprint");
fs::write(
approvals_root.join(format!("v{version}.json")),
canonical_plan_gdd_approval_bytes(&approval)
.expect("fixture approval canonical bytes"),
)
.expect("write fixture approval authority");
}
let error = execute_plan_submit_gdd(&root, &context, &input)
.expect_err("the 129th version is a durable lineage boundary");
assert_eq!(error.code(), "PLAN_NEEDS_RECONCILIATION");
assert!(error.to_string().contains("既有 GDD lineage"));
cleanup_fixture(root);
}
#[test]
fn submit_handler_rejects_second_submission_while_first_is_pending() {
let (root, context, input) = submit_fixture();
@@ -1600,6 +1600,7 @@ pub(crate) fn default_game_creator_agent_runtime_state(
waiting_on: "开发者输入".to_string(),
next_step: "等待输入".to_string(),
loop_iteration: 0,
plan_submit_gdd_rejection_count: 0,
max_loop_iterations: AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT as u32,
tool_action_budget: AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT as u32,
plan_revision: 0,
@@ -245,6 +245,11 @@ struct AgentRuntimeState {
next_step: String,
#[serde(default)]
loop_iteration: u32,
/// Consecutive strict Fast-GDD submit rejections for this exact planning
/// child run. It is Runtime-owned durable state so a restart cannot turn
/// an invalid-provider-output loop back into an unbounded retry.
#[serde(default)]
plan_submit_gdd_rejection_count: u32,
#[serde(default)]
max_loop_iterations: u32,
#[serde(default)]
@@ -30,6 +30,14 @@
- **验证**:`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 M1E 隔离工作树:Fast GDD submit 有界拒绝与覆盖审计
- **范围与结论**:在 `codex/genarrative-isolated` 上按 M1E 只接受具备完整触发链的缺陷。确认 Provider 连续输出不合法 `plan.submit_gdd` 时,Runtime 原有「rejected observation → 同 child run 续跑」链没有次数上限,模型可反复请求 tool-plan 并累积历史 observation;这是可达的 prompt 膨胀与资源消耗链。
- **有界收束**:为 Runtime state 新增 durable `planSubmitGddRejectionCount`。仅本次 Provider input / 候选 GDD 触发的 `PLAN_INVALID_REQUEST`、`PLAN_SIZE_LIMIT` 计数;前四次维持既有 batch abort、observation 与 same-run continuation,第五次仍先完整落 rejected observation,再将该 planning child 终态失败并记录专用失败 audit,不发起第六次 Provider tool-plan。字段以 serde default 向后兼容,进程重启不会重置;新 child run 才从零开始,普通工具 observation 不计入。
- **自审修复**:`PLAN_SIZE_LIMIT` 也可能来自读取既有不可变 GDD/receipt,而非 Provider 输入;`PLAN_VERSION_LIMIT_REACHED` 则由既有 lineage 已达 128 版或其读取异常决定。若只按 error code 分类,会把 durable authority 异常误当可纠正模型输出,最多多跑五次。现将这些读取/版本边界转为 `PLAN_NEEDS_RECONCILIATION`;Provider input/候选 GDD 的大小限制仍可按上项重试,不扩大改变其它 authority 错误语义。
- **覆盖审计**:第 21 节要求的澄清三轮/回答绑定/continuation、submit→receipt 的 replay 与投影恢复、审批后修订、hydrate 空态与恢复均已有真实 Runtime/存储回归。未发现能低成本构成完整新断链的前端或跨层缺陷,因而未为拼接既有单测新增大而脆的 E2E。
- **自审收口**:复核第五次 rejected observation 已持久化、但终态失败写入前进程中断的窗口:原先恢复会再次进入 Provider loop,形成第六次请求。恢复入口现读取 durable counter,达到 5 时直接复用同一终态失败与 audit 收束,不依赖内存 continuation;该修复与原有计数边界完全同域。未发现其它具备完整触发链、且可在 M1E 或此前范围内明确修复的问题;不扩展至 M2 的 `approvedGddRef` 或完整构建绑定。
- **已验证**:新增“第五次终止”“第五次后恢复仍终止”“超限既有 GDD 转 reconciliation”及“lineage 版本上限不作 Provider feedback”四条回归;随后 Rust `planning_` 定向组 **157 passed / 0 failed**,`cargo check --offline --all-targets`、`cargo fmt --check`、`npm run check:encoding`(6608 files)和 `git diff --check` 均通过。仓库既有 Rust warnings 未在本包扩修。M1E 完成。
## 2026-08-18 M1D-2 隔离工作树实现:入口分流与阶段进度
@@ -3,7 +3,7 @@
- 日期:2026-08-10
- 状态:2026-08-12 **M0 代码工作包全部完成**(`M0A-2`、`M0B-1`、`M0B-2` 已合入 M0 集成分支并通过各自门禁,见第 23.4 节);同日 **D6 作废、拓扑改变**,`M0A-1` 交付的文档基线随之失效,需以工作包 `M0A-3` 修订,**修订完成前 M0 不计完整完成**(见第 1.1 节)。2026-08-13 **D9 二次作废、D10 作废,由 D11 取代**:立项策划节点改为 Project Supervisor 通过 `agent.delegate` 发起的静态委派子 Agent,问询复用 PR #165 中转链路(见第 1.1 节「D11 新拓扑」);D11 依赖 WP1(静态委派澄清轮次与返工深度拆分)为强制前置,**该前置已于 2026-08-13 落地并合入**(`WP1` 生产代码 + `WP2` 回归,完成状态与门禁见第 23.5 节),澄清轮次上限现为 3(game-chat source 仍为 1)。随后 `M1A-1`、`M1A-2`、`M1A-3`、`M1A-4` 已分别落地:`M1A-2` 仅收口两层工具面、`project-planning` role brief 注入和 fail-closed 拒绝边界,`M1A-4` 收窄 plan 根 run 的子 Agent 创建面。**2026-08-14 `M1B-1` 已通过门禁并合入本分支**:已落地 `.agent/planning` storage module、strict schema/typed 指纹/canonical parser、GDD 版本链、session 原子恢复、Runtime 写入身份及只挡写门禁;golden vector 与 11 个定向 storage 测试通过,writer/index/recovery 门禁已完成。**2026-08-15 `M1B-2` 工作包已通过本包门禁并以 `27c3eb847` 合入本分支**:已落地 `plan.submit_gdd`、exact planning Provider binding/structured injection、专用提交点与崩溃恢复;本包不包含 `gdd-approval` planning pending、审批等待、receipt、审批命令或 UI。**2026-08-14 `M1C-0` 已合回本分支**:仅新增用户修订状态及 lineage 分类,不包含审批写入方。**2026-08-15 `M1C-0b` 已通过定向门禁并完成**:只改静态委派 durable status 的前向兼容读路径,未知字符串显式保留为 `Unknown(raw)` 并最大化阻塞;不含审批写入方。`M1C-1` 与 `M1C-2a` 已提供审批核心、固定 Goal Contract、完整分页 `file.read` evidence、claim 后三态 acceptance gate、审批 pending 恢复及 completion/finalization 门。**`M1C-2b` 已完成本包实现并通过门禁,现已快进合回 `feat/five_min_design`**:planning 澄清中转、确定性 continuation/session 投影、审批后用户修订谱系、Provider 活跃时间预算与末次 submit usage fold 已实现,11 条 `planning_clarification_*` 回归及关联 Rust 门禁通过。审批 UI、hydrate、构建准入与下游完整构建仍后置,M1 整体不可交付(见第 23.6、23.8 节)。
- 适用范围:AI 游戏创作独立 App、Project Supervisor、Agent Runtime、本地项目策划 sidecar 与后续完整构建准入
- 当前实现边界:本文件是后续详细设计与实现的仓库内阶段基线;M0 工作包冻结 Fast GDD 合同并修复现有 owner 验证、game-chat retry 与前端投影边界,`M1A-1`~`M1A-4` 已提供 plan source、两层工具面、角色 brief 与子 Agent 创建面收窄的 Runtime 基础,`M1C-0` 已提供用户修订 lineage 分类,已合入的 `M1B-1` 提供 storage 基础与写入隔离;`M1B-2` 已提供提交点与恢复,`M1C-0b` 已补齐静态委派未知 durable status 的前向兼容读路径,`M1C-1` 已提供 receipt/审批核心、投影恢复和 plan 根完成门,`M1C-2a` 已补齐固定 Goal Contract、验收图证据与生产 acceptance gate。`M1C-2b` 已实现 planning 澄清中转、三轮确定性 session/continuation 投影、审批后修订谱系和 Provider 活跃时间预算,并已合回 `feat/five_min_design`;`M1D-1` 已提供 hydrate/read model 与审批卡,`M1D-2` 已接入新项目入口分流、阶段进度和实际项目总控页面挂载;approved GDD 构建绑定与完整下游仍留给 M2/M1E
- 当前实现边界:本文件是后续详细设计与实现的仓库内阶段基线;M0 工作包冻结 Fast GDD 合同并修复现有 owner 验证、game-chat retry 与前端投影边界,`M1A-1`~`M1A-4` 已提供 plan source、两层工具面、角色 brief 与子 Agent 创建面收窄的 Runtime 基础,`M1C-0` 已提供用户修订 lineage 分类,已合入的 `M1B-1` 提供 storage 基础与写入隔离;`M1B-2` 已提供提交点与恢复,`M1C-0b` 已补齐静态委派未知 durable status 的前向兼容读路径,`M1C-1` 已提供 receipt/审批核心、投影恢复和 plan 根完成门,`M1C-2a` 已补齐固定 Goal Contract、验收图证据与生产 acceptance gate。`M1C-2b` 已实现 planning 澄清中转、三轮确定性 session/continuation 投影、审批后修订谱系和 Provider 活跃时间预算,并已合回 `feat/five_min_design`;`M1D-1` 已提供 hydrate/read model 与审批卡,`M1D-2` 已接入新项目入口分流、阶段进度和实际项目总控页面挂载;`M1E` 已完成 submit 连续拒绝的有界收束、重启恢复边界与既有回归覆盖审计。M1 策划闭环完成;approved GDD 构建绑定与完整下游留给 M2。
## 1. 背景与目标
@@ -1162,6 +1162,8 @@ GDD handler 只能从已验证 batch binding 复制 `sourceSessionRevision/sourc
4. Provider transient failure/物理中断但 session、context 和 request slot 未变时,才沿用同一 base ID 的 attempt 派生规则。已知 retryable transport/upstream failure 先把旧 attempt durable 闭合为 `failed`;Runner/进程恢复只有在 boot/owner/lease 证据证明旧物理请求不再存活且无 handoff/batch 时,才闭合为 `interrupted`。旧终态写入、同步并回读成功后,才能创建 attempt N+1 的新 `started`;不能原地复用同一 providerRequestId,也不能让两个 started attempt 并存。无法证明旧请求已终止时进入 recovery required,不自动重发。每个 attempt 始终有独立 `started → completed|failed|interrupted` lifecycle。
5. 除第 1~2 项明确允许的同 binding `started + ready batch` 崩溃组合,以及上文 delivery 问题落盘/答案绑定的已消费证明外,binding 缺失/损坏、lifecycle 与 batch 不一致、同 revision 下 requestContextFingerprint 漂移、session 不是合法 successor,或 batch 已进入执行/等待状态时返回 `PLAN_NEEDS_RECONCILIATION`。此路径不自动删除、不补默认 binding、不重绑、不重试。
严格 submit input 被 Runtime 以 `PLAN_INVALID_REQUEST` 或由该输入导出的候选 GDD `PLAN_SIZE_LIMIT` 拒绝时,当前策划子 run 最多产生 **5 次** `plan.submit_gdd / rejected` observation:前 4 次关闭原 sole-action batch 后可在同一 run 续跑,让 Provider 根据最后一条 observation 修正;第 5 次仍须先完整落盘 rejected observation,再把该 run 终态失败,**不得**请求第 6 次 Provider tool-plan。该分类只针对本次 Provider input / 候选 GDD;读取既有不可变 GDD 或 receipt 时出现同名大小上限、既有 lineage 已达版本上限,或任何其它 durable authority 异常,一律是 `PLAN_NEEDS_RECONCILIATION`,不得消耗 Provider 重试额度。计数是 Runtime state 的 durable、每个 child run 独立的字段,进程重启不能清零;只有新建的策划 child run 才从 0 开始。它不依赖前端、Prompt 文字或 Provider 自报,且普通工具 observation 不计入。
第 2 项的自动前滚必须与 session successor、batch supersede/cleanup 和 replacement request 的 started 写入都在项目锁内按幂等步骤恢复;任一断点重启后只能继续相同步骤。这样合法 steer 能确定性替换旧输出,而身份污染不会被“自动恢复”掩盖。
**(2026-08-13 删除)**原此处一整段规定 `plan-decision-checkpoint` 的 stale/repair/superseded 状态机(`repair-{K+1}` 转换、传递闭包数组、handoff 与 successor 的应用边界)。该请求 kind 随 D10 作废,整段无对应物:D11 下续跑是「新 run、同 session」,其 stale 判据就是普通 tool-plan 的那一套(本节第 1~5 项),不需要第二套。
@@ -2025,7 +2027,7 @@ M0 完成不表示完整策划闭环已经上线。`M1A-1`~`M1A-4`、`M1B-1`
| `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`(落地 `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 节测试矩阵中跨层场景 |
| `M1E` | 端到端与故障注入收口 | `M1D-2` | **已完成**:planning 覆盖审计与 submit 拒绝上限收口完成。`PLAN_INVALID_REQUEST` / Provider input 或候选 GDD 的 `PLAN_SIZE_LIMIT` 每 child run 最多 5 次 rejected observation,第 5 次在 observation durable 后终态失败;counter durable,重启不清零。若在第五条 rejected observation 落盘与终态失败之间崩溃,恢复入口会按 durable counter 直接终态失败,不请求第六次 Provider tool-plan。既有不可变 GDD/receipt 的超限读取及 lineage 版本已耗尽均改走 reconciliation,不误耗 Provider 重试额度。第 21 节已存在的三轮、续跑、receipt/replay、投影恢复及 hydrate 回归复核通过;不为“拼接已有单测”新增脆弱大 E2E。 |
**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、编码检查通过。其中锁错误回传绝对路径、阶段进度轮次差一格与 design 组展示名收口三条已于同日补修(见 decision-log 同日两条);仅 hydrate 身份校验与落盘投影修复的顺序一条单列后续,未并入。
@@ -2058,7 +2060,7 @@ M0 完成不表示完整策划闭环已经上线。`M1A-1`~`M1A-4`、`M1B-1`
**为什么不进 `M1C-2b`、由 `M1C-2c` 承接**:`M1C-2b` 的合入门禁(3 轮上限、continuation 重放幂等、答案绑定冲突被拒)与选项文案无关;它按旧映射表完成了澄清中转,并已于 2026-08-17 快进合回。`M1C-2c` 已在隔离 worktree 翻映射、改信封形状校验并更新 role brief、Supervisor playbook 与 final-reply 提示;`planning_clarification_*` 13 条、`project_planning` prompt 5 条、`planning_submit` 定向回归、prompt bundle、格式、编码、diff 和 offline all-targets 门禁均通过。**`M1D-1` 前端决策卡必须直接按本节实现**(label 动态渲染、默认焦点在 A、Other 槽不变),避免做两遍;本包不接前端、hydrate、构建准入或下游完整构建。
**顺带核对项(归 `M1E`)**:`plan.submit_gdd` 连续校验失败必须有**次数**上限。本地实测无界时,模型对大载荷序列化出错后连续 40 余次重试,每次重放全部历史,单次 prompt 涨到 15 万 token;生产的 240/300 秒预算注入兜不住「硬超时注入后仍连续校验失败」的情形。若第 12 节 retry 状态机没有该上限,补一个(原型取 5 次)。
**顺带核对项(`M1E` 已完成)**:`plan.submit_gdd` 连续校验失败有**5 次**上限。本地实测无界时,模型对大载荷序列化出错后连续 40 余次重试,每次重放全部历史,单次 prompt 涨到 15 万 token;生产的 240/300 秒预算注入兜不住「硬超时注入后仍连续校验失败」的情形。第 12 节 retry 状态机现以 durable counter 收束,第五次 rejected observation 落盘后终态失败;恢复入口同样按该 counter 终态收束,不能因两步之间崩溃而发起第六次请求。
## 24. 最终不变量摘要