修复 CI 五条失败:三处校验的位置错误放大了作用域
Project CI / Repository checks (pull_request) Failing after 8s
Project CI / Backend tests (pull_request) Failing after 9s
Project CI / Frontend tests (pull_request) Successful in 3m10s
Project CI / Native shell tests (pull_request) Successful in 18m42s

撤回 M1B-2 对 tool-plan 交接判据的跨 loop 上提并补正向回归

恢复扫描的锚点探测器不再对不可读 state 强读,交还下游 fail-closed 兜底

planning 存储把链接路径统一分类为 PLAN_UNTRUSTED_PATH

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 09:45:44 +00:00
parent ecfc6f1a2a
commit 23559b1b1c
7 changed files with 100 additions and 34 deletions
@@ -67,7 +67,13 @@ fn missing_plan_submit_anchor_candidate_at(
root: &Path,
agent_id: &str,
) -> Result<Option<MissingPlanSubmitAnchorCandidate>, String> {
let runtime = read_game_creator_agent_runtime_at(root, agent_id)?.state;
// 这里只是探测器的前置条件:读不到 state 就不可能匹配「策划子 run 缺锚点」这个形状。
// 不可读 state 的处置属于 finalization 恢复路径(从 task record 重建并 fail-closed 到
// needs-reconciliation);在这条最靠前的探测里用 `?` 会打断整轮 resume,反而绕过那条兜底。
let Ok(result) = read_game_creator_agent_runtime_at(root, agent_id) else {
return Ok(None);
};
let runtime = result.state;
if runtime.phase == "needs-reconciliation"
&& runtime.current_action == "Fast GDD 提交恢复锚点需要人工核对"
{
@@ -2159,8 +2159,7 @@ pub(crate) fn read_plan_gdd_index_with_recovery_locked(
) -> Result<Option<PlanGddIndexV1>, PlanningStorageError> {
validate_timestamp(rebuilt_at_utc, "rebuiltAtUtc")?;
let gdds = read_plan_gdd_chain_locked(root)?;
let target = resolve_local_project_path(root, PLAN_GDD_INDEX_PATH)
.map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?;
let target = resolve_planning_path(root, PLAN_GDD_INDEX_PATH)?;
let target_exists = match fs::symlink_metadata(&target) {
Ok(_) => true,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
@@ -2247,8 +2246,7 @@ pub(crate) fn read_plan_gdd_chain(root: &Path) -> Result<Vec<PlanGddV1>, Plannin
pub(crate) fn read_plan_gdd_chain_locked(
root: &Path,
) -> Result<Vec<PlanGddV1>, PlanningStorageError> {
let planning_root = resolve_local_project_path(root, PLAN_STORAGE_ROOT)
.map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?;
let planning_root = resolve_planning_path(root, PLAN_STORAGE_ROOT)?;
let metadata = match fs::symlink_metadata(&planning_root) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
@@ -2416,6 +2414,35 @@ fn is_version_file_name(path: &str, prefix: &str, approval: bool) -> bool {
}
}
/// Resolve a planning path and classify a linked path component as untrusted
/// rather than merely invalid. The generic resolver folds "component is a
/// symlink" into the same opaque string as every other path error, so mapping
/// its failure wholesale to `PLAN_INVALID_PATH` misreports an untrusted target;
/// `ensure_planning_parent` and `verify_regular_planning_file` already classify
/// links as `PLAN_UNTRUSTED_PATH`, and this keeps the whole module consistent.
fn resolve_planning_path(
root: &Path,
relative_path: &str,
) -> Result<PathBuf, PlanningStorageError> {
let normalized = normalize_relative_path(relative_path)
.map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?;
let mut path = root.to_path_buf();
for part in normalized.split('/') {
path.push(part);
// 只判定链接/重解析点;缺失组件与真实 IO 错误交给通用解析器,保持既有分类。
if fs::symlink_metadata(&path)
.is_ok_and(|metadata| planning_metadata_is_link_or_reparse(&metadata))
{
return Err(PlanningStorageError::new(
"PLAN_UNTRUSTED_PATH",
format!("规划路径组件不能是链接或重解析点:{}", path.display()),
));
}
}
resolve_local_project_path(root, relative_path)
.map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))
}
fn planning_metadata_is_link_or_reparse(metadata: &fs::Metadata) -> bool {
if metadata.file_type().is_symlink() {
return true;
@@ -2924,8 +2951,7 @@ pub(crate) fn durable_create_json_no_replace_locked(
}
reject_noncanonical_storage_bytes(bytes, label)?;
validate_planning_payload_at_root(root, &relative_path, bytes, label)?;
let target = resolve_local_project_path(root, &relative_path)
.map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?;
let target = resolve_planning_path(root, &relative_path)?;
let parent = ensure_planning_parent(&target)?;
if relative_path.starts_with(".agent/planning/gdd.v") {
// Validate the complete on-disk lineage before even considering a
@@ -3036,8 +3062,7 @@ pub(crate) fn write_plan_gdd_index_atomic_locked(
let bytes = canonical_plan_index_bytes(value)?;
let gdds = read_plan_gdd_chain_locked(root)?;
validate_plan_gdd_index_against_gdds(value, &gdds)?;
let target = resolve_local_project_path(root, PLAN_GDD_INDEX_PATH)
.map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?;
let target = resolve_planning_path(root, PLAN_GDD_INDEX_PATH)?;
let parent = ensure_planning_parent(&target)?;
match fs::symlink_metadata(&target) {
Ok(_) => {
@@ -3108,8 +3133,7 @@ pub(crate) fn write_plan_fast_gdd_markdown_atomic_locked(
return Err(invalid("Fast GDD Markdown 必须是无 NUL 的 UTF-8 文本"));
}
let target = resolve_local_project_path(root, PLAN_FAST_GDD_PATH)
.map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?;
let target = resolve_planning_path(root, PLAN_FAST_GDD_PATH)?;
let parent = target
.parent()
.ok_or_else(|| PlanningStorageError::new("PLAN_INVALID_PATH", "Fast GDD 缺少父目录"))?;
@@ -3267,10 +3291,8 @@ pub(crate) fn write_plan_session_atomic_locked(
value: &PlanSessionV1,
) -> Result<(), PlanningStorageError> {
let bytes = canonical_plan_session_bytes(value)?;
let target = resolve_local_project_path(root, PLAN_SESSION_PATH)
.map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?;
let previous = resolve_local_project_path(root, PLAN_SESSION_PREVIOUS_PATH)
.map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?;
let target = resolve_planning_path(root, PLAN_SESSION_PATH)?;
let previous = resolve_planning_path(root, PLAN_SESSION_PREVIOUS_PATH)?;
let parent = ensure_planning_parent(&target)?;
let target_state = match fs::symlink_metadata(&target) {
Ok(_) => {
@@ -3369,10 +3391,8 @@ fn read_optional_plan_session_file(
pub(crate) fn read_plan_session_with_recovery(
root: &Path,
) -> Result<Option<PlanSessionV1>, PlanningStorageError> {
let primary_path = resolve_local_project_path(root, PLAN_SESSION_PATH)
.map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?;
let previous_path = resolve_local_project_path(root, PLAN_SESSION_PREVIOUS_PATH)
.map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?;
let primary_path = resolve_planning_path(root, PLAN_SESSION_PATH)?;
let previous_path = resolve_planning_path(root, PLAN_SESSION_PREVIOUS_PATH)?;
let primary_exists = fs::symlink_metadata(&primary_path)
.map(|_| true)
.or_else(|error| {
@@ -3404,20 +3424,16 @@ pub(crate) fn read_plan_session_with_recovery(
pub(crate) fn read_plan_session_with_recovery_locked(
root: &Path,
) -> Result<Option<PlanSessionV1>, PlanningStorageError> {
let primary_path = resolve_local_project_path(root, PLAN_SESSION_PATH)
.map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?;
let previous_path = resolve_local_project_path(root, PLAN_SESSION_PREVIOUS_PATH)
.map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?;
let primary_path = resolve_planning_path(root, PLAN_SESSION_PATH)?;
let previous_path = resolve_planning_path(root, PLAN_SESSION_PREVIOUS_PATH)?;
let primary_state = read_optional_plan_session_file(&primary_path, "plan session primary")?;
let previous_state = read_optional_plan_session_file(&previous_path, "plan session previous")?;
match (primary_state, previous_state) {
(None, None) => Ok(None),
(Some(primary), None) => Ok(Some(primary)),
(None, Some(previous)) => {
let primary_path = resolve_local_project_path(root, PLAN_SESSION_PATH)
.map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?;
let previous_path = resolve_local_project_path(root, PLAN_SESSION_PREVIOUS_PATH)
.map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?;
let primary_path = resolve_planning_path(root, PLAN_SESSION_PATH)?;
let previous_path = resolve_planning_path(root, PLAN_SESSION_PREVIOUS_PATH)?;
// Re-read while holding the project lock. A writer may have
// published a new primary between the optimistic read and lock
// acquisition; never overwrite that newer fact.
@@ -75,12 +75,11 @@ pub(super) fn validate_next_entry(
if !same_durable_tool_plan_run(&previous.identity, &candidate.identity) {
return Err("tool-plan 成功响应交接 entry 的 durable run 身份冲突".to_string());
}
if !same_tool_plan_repair_chain(&previous.identity, &candidate.identity) {
return Err(
"tool-plan 成功响应交接 entry 的 Provider/session binding 链身份冲突".to_string(),
);
}
if candidate.loop_iteration == previous.loop_iteration {
// repair 链判据包含 steer cursor、goal revision/快照与 planning session
// binding 这些**本轮量**,只有同 loop 的 repair 之间才必须逐位相等。跨 loop
// 续跑时它们本来就会前进,把这条判据提到 loop 分支之外会把正常续跑判成交接
// 失败并打进 needs-reconciliation;跨 loop 的身份由 same_durable_tool_plan_run 守。
if !same_tool_plan_repair_chain(&previous.identity, &candidate.identity) {
return Err("tool-plan 成功响应交接同 loop repair 链身份冲突".to_string());
}
@@ -434,6 +434,26 @@ fn tool_plan_handoff_reports_identity_and_payload_conflicts() {
assert!(error.contains("同一 slot 内容冲突"));
}
#[test]
fn tool_plan_handoff_accepts_new_loop_after_steer_and_goal_revision_advance() {
let project = tempdir().expect("tool-plan handoff project");
let base = identity("loop-0-repair-0");
write(project.path(), &base, 0, &response("base", Vec::new()));
// 进入新 loop 本身就意味着 steer cursor / goal revision 已经前进,跨 loop 不能套用
// 同 loop 的 repair 链判据,否则正常续跑会被判成交接失败。
let mut next_loop = identity("loop-1-repair-0");
next_loop.applied_steer_cursor += 1;
next_loop.goal_revision += 1;
next_loop.goal_snapshot_fingerprint = "c".repeat(64);
write(
project.path(),
&next_loop,
0,
&response("next loop", Vec::new()),
);
}
#[test]
fn tool_plan_handoff_rejects_out_of_order_entries() {
let project = tempdir().expect("tool-plan handoff project");
@@ -1,5 +1,19 @@
# 决策记录
## 2026-08-15 CI 五条失败归并为三个根因:修位置,不修症状
栈溢出修复后的全量跑出 5 条失败(`2051 passed / 5 failed`,**零栈溢出**,栈修复站住)。逐条定位后归并为 3 个根因,**全部先于本轮工作**:与栈修复、`M1C-0b`、以及刚合入的 master 都无关,master`9f5c84ee7`)自身全绿,`deae1e08c`(栈修复前、`M1C-0b` 前)已全挂。三者形状相同——**新增的检查被放到了链路更靠前的位置,改变的是作用域而非严格程度**,详见 pitfalls 同日条。
- **归属**`tool_plan_handoff_rejects_out_of_order_entries` + 两条 `tests::goal``provider_action_batch_goal_resume_never_rewinds_newer_steer_cursor``agent_goal_paused_edit_replans_old_confirmation_in_same_run`)→ `M1B-2``27c3eb847`)的 `validate_next_entry` 判据上提;`response_stream::structured_plan_finalization_without_readable_runtime_state_needs_reconciliation` → 同一提交新增的 `missing_plan_submit_anchor_candidate_at` 强读;`immutable_writer_rejects_symlink_and_hardlink_targets``M1B-1``f453c2ca2`)起就没绿过的错误码分类,Windows 上编译都不参与故一直不可见。
- **证据不是推测**:失败用例遗留的临时项目目录里,`.agent/agent.db` 记着 `failureKind=tool-plan-integrity``errorChars=55``errorSha256=0750f609…`,与 `M1B-2` 新增那句「`tool-plan 成功响应交接 entry 的 Provider/session binding 链身份冲突`」的 SHA-256 与字符数逐位相同;`requestSlot``loop-1-repair-0` 走到 `loop-2-repair-0`,直接指认是跨 loop 那一跳被误判。
- **裁决一:撤回上提,而不是放宽判据本身。** `same_tool_plan_repair_chain` 含 steer cursor、goal revision/快照与 planning session binding 这些本轮量,进入新 loop 本就意味着它们前进;这条判据只在同 loop 的 repair 之间成立。**撤回不留缺口**:跨 loop 的 durable 身份由 `same_durable_tool_plan_run` 守,binding 漂移由 `provider_retry.rs``..._drift_fields`(含 `planningSessionBinding`)在**每个请求**层面守,`ledger.rs``is_later_repair_identity` 一直就把这条判据限定在同 loop——上提是模块内唯一的例外。**未采纳**「保留跨 loop 检查但只比 durable 子集」:`gdd_id` 在策划子 run 内会从 `None``Some``goal_id` 亦非绝对不变,凭空发明一条无测试支撑的新不变量,对一个管 Provider 计费与身份的安全屏障不划算。上提本身也**没有任何测试**(`M1B-2` 在该文件只机械补了一行 `planning_session_binding: None`)。
- **裁决二:探测器不得对自己的前置条件 fail-fast。** `missing_plan_submit_anchor_candidate_at` 是机会性修复,不是门。state 读不出来就不可能匹配它要找的形状,改为 `Ok(None)`;不可读 state 的处置权归下游 `resume_game_creator_agent_finalization_at`(从 task record 重建并 fail-closed 到 `needs-reconciliation`)。**不算掩盖**:紧随其后的那一步照样会读同一个文件并留下 `reason=runtime-state-missing` 的记录。**未采纳**「把探测器挪到 finalization 之后」——那会让 `Recovered`/`Blocked` 分支的 `continue` 直接跳过探测,改动的是语义而不是位置。
- **裁决三:链接一律按不可信路径分类,且模块内统一。** 新增 `resolve_planning_path`:先用模块自己的 `planning_metadata_is_link_or_reparse` 逐组件判链接/重解析点(比通用解析器的 `is_symlink()` 多覆盖 Windows reparse point),命中返回 `PLAN_UNTRUSTED_PATH`,其余仍交通用解析器并保持 `PLAN_INVALID_PATH`。模块内 13 处解析全部改走它。**未采纳**「改测试去迁就现状」——同模块 `ensure_planning_parent``verify_regular_planning_file` 都把链接判为 `PLAN_UNTRUSTED_PATH`,测试写的才是既定语义。已确认无生产代码或其它测试对这两个码做分支(全仓库仅这两处断言)。
- **回归钉边界,不只钉拒绝**:新增 `tool_plan_handoff_accepts_new_loop_after_steer_and_goal_revision_advance`——跨 loop 且 steer cursor / goal revision 已前进必须被**接受**。原有用例只钉「什么该拒」,所以判据作用域被放大时无人报警。
- **验证**:4 条可在 Windows 复现的用例全绿(含新增回归);Linux-only 那条在 WSL 上跑通。同轮曾出现 3 条 `response_stream` 超时失败,空载单独复跑 3 passed / 0 failed,且三者走 `final-reply` 路径、不经过 `validate_next_entry`,与本次改动无因果——判为负载抖动。
- **未做**:不追查这 5 条各自的完整历史绿/红轨迹(引入点已锁定到单个提交,继续二分无增量);不动 `M1B-2` 的功能面。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/{identity_order_validation.rs,tests.rs}``.../agent/runtime_driver/recovery_scan.rs``.../agent/runtime_protocol/planning_storage.rs`pitfalls.md 同日「把校验往链路前面挪」条。
## 2026-08-15 恢复重启栈溢出:主循环专用 worker 从「枚举入口」改为不变量
CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task``tokio-rt-worker` 栈溢出并 SIGABRT。属于本文件 pitfalls「Runtime 后台执行不能让大型 async frame 共用默认 worker 栈」的同一失败类,但暴露出该条目的判据形式本身有缺陷。**Windows 上可直接复现,不必去 Linux/WSL**。
@@ -1,5 +1,16 @@
# 踩坑与排障记录
## 2026-08-15 把校验往链路前面挪,改的不是严格程度而是作用域
- 现象:CI 全量 5 条失败,看上去毫不相干(两条 Goal 续跑停在 `needs-reconciliation`、一条交接用例断言错误文案、一条恢复用例把不可读 state 的错误抛了出来、一条 Linux-only 用例错误码对不上),实际只有 3 个根因,且三者是**同一个形状**:新增或既有的检查被放在了链路更靠前的位置,于是它的语义作用域被悄悄放大或提前,而不是「变严」。
- 形状一(判据上提 → 作用域从同 loop 变成跨 loop):`tool_plan_handoff/identity_order_validation.rs``same_tool_plan_repair_chain` 含 steer cursor、goal revision/快照与 planning session binding 这些**本轮量**,只在同 loop 的 repair 之间才必须逐位相等。`M1B-2``27c3eb847`)把它提到 `loop_iteration` 分支之外后,`loop-N → loop-N+1` 的正常续跑必然被判成「身份冲突」,整个 run 进 `needs-reconciliation`。**同一次上提还把分支内那句同名检查变成了死代码**——编译器不报,测试拿到的是外层文案,于是表现成「断言的错误文案不对」这种看起来无关的症状。模块内本来就有反例可对照:`ledger.rs``is_later_repair_identity` 一直把这条判据显式限定在 `current_loop == candidate_loop`
- 形状二(探测器的前置条件变成整条链路的 gate):`recovery_scan.rs``missing_plan_submit_anchor_candidate_at``?` 强读 runtime state,而它被挂在每个 Agent resume 循环的**最前面**。真正负责处置「state 不可读」的是它下游的 `resume_game_creator_agent_finalization_at`——那里会从 task record 重建并 fail-closed 到 `needs-reconciliation`。前面这一 `?` 把整轮 resume 打断,**恰好绕过了专门为这种情况写的兜底**。这个探测器甚至不适用于出事的 Agent(它只认 planning agent + `agent-delegate`),读失败纯属前置成本。
- 形状三(通用解析器排在专用校验之前,错误码被压平):`planning_storage.rs``resolve_local_project_path` 的失败整体映射成 `PLAN_INVALID_PATH`,但该解析器会先于 planning 自己的校验逐组件拒绝符号链接。于是「不可信路径」被报成「非法路径」,与同模块 `ensure_planning_parent``verify_regular_planning_file` 的分类自相矛盾。这条自 `M1B-1``f453c2ca2`)写下就没绿过——用例是 `#[cfg(unix)]`Windows 上编译都不参与(`0 tests`),只有 Linux CI 能看见。
- 处理:形状一改回 loop 分支内,并补一条正向回归(跨 loop 且 steer cursor / goal revision 已前进必须被接受),把边界钉住而不是只钉拒绝;跨 loop 身份由 `same_durable_tool_plan_run` 守,binding 漂移另有 `provider_retry.rs` 的 per-request 判据兜底,去掉上提不留缺口。形状二把强读改成「读不到就 `Ok(None)`」,让处置权回到下游兜底。形状三新增 `resolve_planning_path`,先用模块自己的 `planning_metadata_is_link_or_reparse` 判链接/重解析点并返回 `PLAN_UNTRUSTED_PATH`,再交给通用解析器;模块内 13 处解析全部改走它,分类统一。
- 定位手法(比二分快得多):失败用例的临时项目目录在 panic 后不会被清理(`fs::remove_dir_all` 写在用例末尾),直接读里面的 `.agent/agent.db``.agent/runtime/agents/*.json`。本次两条 Goal 失败在 db 里留下 `failureKind=tool-plan-integrity``errorChars=55``errorSha256=0750f609…`,把候选错误文案逐条算 SHA-256 一比即命中,`requestSlot``loop-1-repair-0``loop-2-repair-0` 直接指出是跨 loop 那一跳。**错误只留指纹不留原文时,指纹就是可检索的**。
- 验证:4 条可在 Windows 复现的用例全部转绿(含新增回归);Linux-only 那条在 WSL 上跑通(`/home/dev/agc` + `CARGO_TARGET_DIR=/home/dev/agc-app-target`,从 Windows 工作树 `git fetch` 后打 patch 验证,验完还原分支)。注意 `ai-game-creator-app``Genarrative` 的 git worktreeWSL 里不能直接 fetch 它的路径(`.git` 文件写的是 Windows 路径),要从 `/mnt/c/projects/narrative/Genarrative` fetch。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/identity_order_validation.rs``.../agent/runtime_driver/recovery_scan.rs``.../agent/runtime_protocol/planning_storage.rs`decision-log 同日条。
## 2026-08-15 `#[cfg(windows)]` 里的代码不参与 Linux CI 编译,CI 绿不代表能构建
- 现象:把 master`9f5c84ee7`)合进 `feat/five_min_design` 后,`cargo check --all-targets` 在 Windows 上直接 `error[E0658]: use of unstable library feature 'windows_by_handle'`,位置是 `apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs``metadata.number_of_links()`。该文件与 `origin/master` **逐字节相同**,即 master 自身在 Windows 上就构建不过。
@@ -2005,8 +2005,8 @@ M0 完成不表示完整策划闭环已经上线。`M1A-1``M1A-4`、`M1B-1`
| `M1A-2` | 两层工具面 + `project-planning` 角色 brief | `M1A-1` | **已落地**Supervisor 根 run 保持 standard 工具面;planning 子 Agent 按 `agent-delegate`/`standard`/Supervisor parent 身份构建 exact native allowlist`file.read``file.list` 与两个协议控制函数),MCP 为空、web search 关闭、collaboration policy 为 `null`Prompt Bundle brief 只注入 `project-planning`repair/rebuild 与初始请求一致;伪造写入、命令、MCP、`project.search` alias、`user.input_request` 等原始工具在广告/解析/执行/恢复路径均 fail-closed。**不包含 `plan.submit_gdd` 注册或 GDD 提交/审批。** |
| `M1A-3` | plan 根 run 强判据函数 + retry 保源(本节下方「`M1A-3` 的由来」,规格见第 4.1 节第 5、7 段) | `M1A-1` | **已落地**source 保源与强判据)。`kind=plan-root-retry-identity-unsupported`gui/cli 兜底不变。第 4.1 节第 7 段里依赖 plan-session / `gddId` / `gdd-approval` kind 的部分仍后置。必须早于 `M1C-2a` |
| `M1A-4` | plan 根 run 的子 Agent 创建面收窄:`agent.delegate` 目标必须是 `project-planning``agent.spawn_isolated` 一律拒;plan source 下不拼 `supervisorIntro``$visualContract` | `M1A-2` | 执行层与上下文层**都要做且不可互相替代**(同第 19 节第 2 条纪律);`agent.spawn_isolated` 是第二条造子 Agent 的通道,只堵 `agent.delegate` 视为未完成;强判据 `Err` 必须落进拒绝分支而非当作「不是 plan 根」;`project-supervisor-gui/cli` 的委派与 spawn 行为正常路径不变。**必须早于 `M1D-2``M1E`** |
| `M1B-1` | `.agent/planning/` 存储层、strict schema、typed 指纹、版本链;含 `.agent/planning/**` 只挡写判据 | `M1A-2` | **2026-08-14 已通过门禁并合入本分支**GDD / index / session strict DTO、canonical bytes、duplicate-key 与后缀门、typed 指纹、连续版本链、session 原子写入/恢复、Runtime writer identity 及 `game/fast_gdd.md` / `.agent/planning/**` 通用写入拒绝;第 9.1 节 golden vector3857 bytes)与 11 个定向 storage 测试通过,writer 目标 schema 重验、index 权威对账与锁内 index recovery(缺失/损坏/陈旧从严格 GDD 链重建)、恢复故障矩阵、`cargo check --offline``npm run check:encoding``git diff --check` 均通过。合入时无 approval receipt schema,多版本 `statusCache` 只是无 receipt 的预审批投影;M1C-1 接入 receipt 后必须重建 `approved/revise/reject/superseded` 状态。create-only 与等前缀不可变 |
| `M1B-2` | `plan.submit_gdd` 原生工具、exact planning Provider 请求绑定与 GDD 提交点 | `M1B-1` | **工作包已通过本包门禁并以 `27c3eb847` 合入本分支**。实现合同:四类 request kind 全部写 v3 lifecycle、required binding 与同一 dedicated structured-injection user message;只有 `tool-plan` 可生成 sole-submit v4 batch;提交点后只修复 index/Markdown/session successor、终止策划子 run,并保留 generic v5 standalone pending + v4 batch anchors,不创建 `gdd-approval` planning pending/审批等待。定向 Rust、`cargo check --offline`、格式、编码与 diff 门禁均已通过;完整审批链路与产品可交付仍留给后续工作包 |
| `M1B-1` | `.agent/planning/` 存储层、strict schema、typed 指纹、版本链;含 `.agent/planning/**` 只挡写判据 | `M1A-2` | **2026-08-14 已通过门禁并合入本分支**GDD / index / session strict DTO、canonical bytes、duplicate-key 与后缀门、typed 指纹、连续版本链、session 原子写入/恢复、Runtime writer identity 及 `game/fast_gdd.md` / `.agent/planning/**` 通用写入拒绝;第 9.1 节 golden vector3857 bytes)与 11 个定向 storage 测试通过,writer 目标 schema 重验、index 权威对账与锁内 index recovery(缺失/损坏/陈旧从严格 GDD 链重建)、恢复故障矩阵、`cargo check --offline``npm run check:encoding``git diff --check` 均通过。**2026-08-15 订正**:本包合入时 `immutable_writer_rejects_symlink_and_hardlink_targets` 实际从未通过——该用例是 `#[cfg(unix)]`Windows 上不参与编译(`0 tests`),本包门禁在 Windows 上跑因而看不见;符号链接被通用路径解析器先行拒绝并压成 `PLAN_INVALID_PATH`,与本模块「链接一律 `PLAN_UNTRUSTED_PATH`」的分类冲突。已修(详见 `decision-log.md` 同日条裁决三)。合入时无 approval receipt schema,多版本 `statusCache` 只是无 receipt 的预审批投影;M1C-1 接入 receipt 后必须重建 `approved/revise/reject/superseded` 状态。create-only 与等前缀不可变 |
| `M1B-2` | `plan.submit_gdd` 原生工具、exact planning Provider 请求绑定与 GDD 提交点 | `M1B-1` | **工作包已通过本包门禁并以 `27c3eb847` 合入本分支**。实现合同:四类 request kind 全部写 v3 lifecycle、required binding 与同一 dedicated structured-injection user message;只有 `tool-plan` 可生成 sole-submit v4 batch;提交点后只修复 index/Markdown/session successor、终止策划子 run,并保留 generic v5 standalone pending + v4 batch anchors,不创建 `gdd-approval` planning pending/审批等待。定向 Rust、`cargo check --offline`、格式、编码与 diff 门禁均已通过;完整审批链路与产品可交付仍留给后续工作包。**2026-08-15 订正:本包的定向门禁漏掉了两处跨包回归,全量 CI 才暴露**——① `validate_next_entry``same_tool_plan_repair_chain` 提到 `loop_iteration` 分支之外,使含本轮量(steer cursor / goal revision / planning binding)的判据作用于跨 loop 续跑,两条 `tests::goal` 与一条交接用例失败;② 新增的 `missing_plan_submit_anchor_candidate_at` 在 resume 最前面强读 runtime state,短路了下游对不可读 state 的 fail-closed 兜底。均已修,详见 `decision-log.md` 同日条裁决一/二。**教训已记入门禁**:新增或移动校验必须同时补一条「正向必须被接受」的回归,只钉拒绝挡不住作用域被放大 |
| `M1C-0` | 新增 `StaticDelegateContractStatus::UserRevisionRequested` 与分类分支 | `M1A-1` | **已落地**:无审批写入方、是惰性路径;用户修订跳不增 `repair_depth` 也不重置 `clarification_round`,连续修订可通过;做游戏链路返工仍在 `depth=1` 被拒;原有三种状态与 `UserRevisionRequested` 的已知行为保持不变,未知 durable status 的前向兼容由已完成的 `M1C-0b` 显式承接,不在本包静默降级或改变 |
| `M1C-0b` | 静态委派 durable enum 的前向兼容粒度:未知 `contract_status` 解析为显式 `Unknown` 并最大化阻塞 | `M1C-0` | **已完成**:纯读路径、无写入方、审批状态、pending、receipt 或 UI(不含 `M1C-1`)。四种已知 durable 值保持原 serde;未知字符串解析为 `Unknown(raw)`,非字符串仍拒绝,`Serialize` 及读-改-写均原样保留 raw。`Unknown` 计入 completion barrier 与 waiting blocker,返工入口无条件拒绝(含 `depth=0`),lineage 按“其它”最保守分类(`depth + 1``round = 0`);planning Provider、自治 liveness、终态扫描等既有读路径同步 fail closed。截断、非法 JSON、非 UTF-8、超过 128 KiB 的 sidecar 仍按整目录 fail closed,不做单条跳过。**不新增或改变 `M1B-*` 功能依赖(仅复核其既有读路径);不包含 `M1C-1` 的审批写入、receipt、UI 或构建准入** |
| `M1C-1` | `gdd-approval` pending、审批命令、receiptreceipt 写入上述 status | `M1B-2``M1C-0`(前向兼容粒度另见 `M1C-0b`) | 三动作全通;版本+指纹竞态防护;两窗口并发;**连续多次修订均可通过且 `repair_depth` 不变**。**`M1C-0` 合入复核留下的三条已于 2026-08-15 裁决**(全文见第 23.7 节「落地约束」裁决一/二/三与 `decision-log.md` 同日条):① 新增独立 barrier 计数 `user_revision_pending_count`,不并进现有两个,且**不带** `repair_of.is_none()`——否则第 2 次及以后的修订不阻塞;另有四处逐字段读 barrier 的调用点需逐处裁决;② `validate_static_delegate_structured_result` 复用 `EvidenceReady` 的三条客观证据约束,并把该处 if/else-if 链改成穷尽 `match`,但**不得更严**——该函数每次读取都跑,过严会把写入方 bug 变成 delivery 永久读不出;③ 前向兼容粒度移交 `M1C-0b`,本包二选一:`M1C-0b` 先落,或直接上线并在 release note 明写「用过策划审批后回滚旧版本会让该项目静态委派面不可用」 |