P4:先识别 Fast GDD 再走绑定父链,别让祖先坏掉误伤非策划 run

plan_root_completion_identity_at 读绑定用的是会遍历并校验整条祖先链的入口,
而识别 Fast GDD 只看 run 自己那条记录的 source/profile。链走排在识别前面,于是
任何祖先绑定的毛病都先变成 Err,再被完成门统一翻成 needs-reconciliation——扣在一个
下一行本来就会被判「不是策划根」的 run 头上,让它再也收束不了。

可达:task_start 里 requires_public_start_status 明确把 agent-delegate-receipt 与
agent-isolated-join 排除在「无父的公开启动」之外,带父链的 supervisor run 在生产中
确实存在。新增复现用例在修复前报 Some(NeedsReconciliation)。

改动安全的两条依据:
- 两个入口对同一个 (agent, run) 返回的绑定值完全相同(链走版本最后返回的就是 run
  自己那条记录),链走纯属校验副作用,识别判据一字未变。
- 策划根的严格度一点没降:validate_project_supervisor_plan_root_binding_at 内部读的
  就是链走版本,且强制 parent 必须为空。被移除的只是即将判定「不是策划根」那条路径上
  的链走,顺带消掉同一条绑定被连着走两遍父链。

另外把 PLAN_GDD_APPROVAL_SOURCE 与 AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE 必须同值钉成
编译期断言:识别用前者、复核用后者,两者分处不同模块各自定义,一旦分叉每个策划根都会
先通过识别再被复核拒掉,全部塌成 needs-reconciliation,而且没有测试会指向这个原因。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 07:08:47 +00:00
parent f8568f77db
commit cb5af31e79
@@ -1579,6 +1579,22 @@ fn plan_gdd_session_matches_receipt(
})
}
// 这道门用 `PLAN_GDD_APPROVAL_SOURCE` 识别策划根,而随后的
// `validate_project_supervisor_plan_root_binding_at` 用 `AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE`
// 复核同一条绑定。两个常量分别定义在 planning_storage 与 runtime_driver,值必须相同:
// 一旦分叉,每个策划根都会先通过识别、再被复核拒掉,全部塌成 needs-reconciliation,
// 而且没有任何测试会直接指向这个原因。钉成编译期条件。
const _: () = {
let identified = PLAN_GDD_APPROVAL_SOURCE.as_bytes();
let validated = AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE.as_bytes();
assert!(identified.len() == validated.len());
let mut index = 0;
while index < identified.len() {
assert!(identified[index] == validated[index]);
index += 1;
}
};
fn plan_root_completion_identity_at(
root: &Path,
agent_id: &str,
@@ -1587,8 +1603,15 @@ fn plan_root_completion_identity_at(
if agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID || run_id.trim().is_empty() {
return Ok(false);
}
// 识别只看 run 自己那条绑定记录。链走版本会遍历并校验整条祖先链;把它排在识别
// 前面,等于让任何祖先绑定的毛病都先变成 Err,而调用方会把 Err 一律翻成
// needs-reconciliation——扣在一个下一行本来就会被判「不是策划根」的 run 头上,让它
// 再也收束不了。被误伤的只可能是非策划 run:真正的策划根无父(下面
// `validate_project_supervisor_plan_root_binding_at` 强制 parent 必须为空),链走
// 对它本就是空转。两个入口对同一个 (agent, run) 返回的绑定值完全相同,差别仅在于
// 是否顺带校验祖先链,所以识别判据一字未变。
let Some(binding) =
read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id)?
read_game_creator_agent_runtime_run_profile_binding_once(root, agent_id, run_id.trim())?
else {
return Ok(false);
};
@@ -1597,6 +1620,8 @@ fn plan_root_completion_identity_at(
{
return Ok(false);
}
// 确认是策划根之后才走完整父链:严格度一点没降——
// `validate_project_supervisor_plan_root_binding_at` 内部读的就是链走版本。
let binding = validate_project_supervisor_plan_root_binding_at(root, agent_id, run_id)?;
let runtime = read_game_creator_agent_runtime_at(root, agent_id)?.state;
if runtime.run_id != run_id
@@ -2053,4 +2078,69 @@ mod tests {
);
let _ = fs::remove_dir_all(root);
}
/// 祖先绑定坏掉不能让一个跟立项策划无关的 supervisor run 被判成「策划根身份不明」。
///
/// `plan_root_completion_identity_at` 读绑定走的是会遍历完整父链的入口,而识别
/// Fast GDD 只看 run 自己那条记录的 source/profile。链走排在识别前面,于是任何
/// 祖先绑定的问题都先变成 Err,再被完成门统一翻成 needs-reconciliation——扣在一个
/// 下一行就会被判为「不是策划根」的 run 头上,让它再也收束不了。
///
/// 真正的策划根没有父(`validate_project_supervisor_plan_root_binding_at` 强制
/// parent 必须为空),所以链走对它本来就是空转;会被链走误伤的只可能是非策划 run。
#[test]
fn broken_ancestor_binding_must_not_make_a_non_plan_supervisor_run_a_plan_root_suspect() {
let root = std::env::temp_dir().join(format!(
"genarrative-plan-root-identity-{}",
uuid::Uuid::new_v4().simple()
));
init_local_game_project_at(&root, "project-test-plan-root", "plan root identity")
.expect("init project");
let parent_run_id = "p4-isolated-join-parent";
let child_run_id = "p4-isolated-join-child";
bind_game_creator_agent_runtime_run_profile_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
parent_run_id,
AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE,
Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD),
None,
)
.expect("bind ancestor run");
bind_game_creator_agent_runtime_run_profile_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
child_run_id,
AGENT_RUNTIME_ISOLATED_JOIN_SOURCE,
Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD),
Some(&AgentRuntimeTaskLink {
parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()),
parent_run_id: Some(parent_run_id.to_string()),
delegation_id: None,
}),
)
.expect("bind isolated-join child run");
// 破坏祖先:子记录仍带着 parentBindingFingerprint,父绑定却没了,正好命中
// 父链遍历里的「父绑定缺失」。子 run 自己那条记录始终是完好可读的。
fs::remove_file(game_creator_agent_runtime_run_profile_binding_path(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
parent_run_id,
))
.expect("remove ancestor binding");
let blocker = plan_gdd_typed_completion_blocker_at_locked(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
child_run_id,
);
assert!(
blocker.is_none(),
"isolated-join run 的 source 不是立项策划,完成门不该拦它,更不该说它策划根身份不明:{:?}",
blocker.map(|blocker| blocker.kind)
);
let _ = fs::remove_dir_all(root);
}
}