P1:窄投影恢复失败不再掐掉整轮 resume,plan_gdd blocker 改类型化判别

A. reconcile_plan_gdd_approval_projections_at 挂在 resume 的第一行却用 `?` 强传播,
一次 Fast GDD 投影失败会掐掉全项目所有 Agent 的恢复;而它本身正是 receipt 投影失败
后的重试入口,掐掉它等于连兜底一起废掉。改成把 fail-closed 收敛到策划根 Supervisor
这个 run,其余 Agent 照常恢复;无法归属时才退回全局上抛。

B. main_loop 原来用 contains("approvalPending=awaiting_decision") 区分 plan_gdd
blocker 的子状态,而三个 blocked 里只有一个含这个子串——尚未提交(下一步是
agent.delegate)和 receipt 锚点收尾都会掉进 else 被打成 needs-reconciliation,把最
正常的推进态当成故障停掉。改由构造方给出 PlanGddCompletionBlockerKind,消费方穷尽
match,判不出 kind 时保持 fail-closed。这两个推进态不再产生等待态,和
runtime.plan_update 一样让本轮循环继续。

映射抽成纯函数并建起 main_loop 至今没有的 mod tests:四个 kind × 两条映射全覆盖。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 04:02:54 +00:00
parent c89a13e682
commit a40080d525
4 changed files with 406 additions and 48 deletions
@@ -1,5 +1,86 @@
use super::*;
struct PlanGddBlockerRuntimeProjection {
phase: &'static str,
current_action: &'static str,
waiting_on: &'static str,
next_step: &'static str,
}
/// `runtime.plan_gdd` blocker 的类型化子状态 → 运行时投影。
///
/// 抽成纯函数是为了让这条分支可测:主循环整个文件此前没有 `mod tests`,而原来的
/// 判别是对 detail 做 `contains("approvalPending=awaiting_decision")`——三个 blocked
/// 子状态里只有一个含这个子串,另外两个会掉进 else 被打成 needs-reconciliation
/// 把最正常的早期推进态和收尾态当成故障停掉。判不出 kind 时保持 fail-closed。
fn plan_gdd_blocker_runtime_projection(
kind: Option<PlanGddCompletionBlockerKind>,
) -> PlanGddBlockerRuntimeProjection {
match kind {
Some(PlanGddCompletionBlockerKind::SubmissionNotStarted) => {
PlanGddBlockerRuntimeProjection {
phase: "planning",
current_action: "推进本根 Run 的 Fast GDD 提交",
waiting_on: "策划子 Agent 完成本根 Run 的 plan.submit_gdd",
next_step: "调用 agent.delegate 派出策划子 Agent;上一根 Run 遗留的 game/fast_gdd.md 或 Acceptance Graph 不能代替本根提交",
}
}
Some(PlanGddCompletionBlockerKind::AwaitingApprovalDecision) => {
PlanGddBlockerRuntimeProjection {
phase: "waiting-for-user-input",
current_action: "等待 Fast GDD 审批决定",
waiting_on: "用户在审批卡选择批准、修改或退回",
next_step: "等待 decide_game_creator_plan_gdd;不得重新提交同一 GDD 或自行创建审批 pending",
}
}
Some(PlanGddCompletionBlockerKind::ReceiptAnchorCleanupPending) => {
PlanGddBlockerRuntimeProjection {
phase: "planning",
current_action: "等待 Fast GDD 审批投影收尾",
waiting_on: "原 plan.submit_gdd 恢复锚点由审批投影清理",
next_step: "等待审批投影恢复清理锚点后继续;不得重新提交同一 GDD 或自行创建审批 pending",
}
}
Some(PlanGddCompletionBlockerKind::NeedsReconciliation) | None => {
PlanGddBlockerRuntimeProjection {
phase: "needs-reconciliation",
current_action: "Fast GDD 审批投影需要人工核对",
waiting_on:
"planning pending、receipt、原提交锚点、terminal observation、audit 与 session 的精确身份",
next_step: "先恢复或核对现有 durable 事实,不能请求新 Provider 计划",
}
}
}
}
/// 只有「等用户决定」和「要人工核对」才终结本轮后台任务;尚未提交与锚点收尾都是
/// 继续推进态,和 `runtime.plan_update` 一样不产生等待态,让本轮循环继续。
fn plan_gdd_blocker_waiting_kind(
kind: Option<PlanGddCompletionBlockerKind>,
) -> Option<(
&'static str,
&'static str,
&'static str,
AgentBackgroundTaskOutcome,
)> {
match kind {
Some(PlanGddCompletionBlockerKind::AwaitingApprovalDecision) => Some((
"agent.runtime.plan.gdd.waiting",
"waiting-for-user-input",
"Fast GDD 审批等待状态持久化失败",
AgentBackgroundTaskOutcome::WaitingForUserInput,
)),
Some(PlanGddCompletionBlockerKind::NeedsReconciliation) | None => Some((
"agent.runtime.plan.gdd.reconciliation",
"needs-reconciliation",
"Fast GDD 审批投影需要人工核对",
AgentBackgroundTaskOutcome::NeedsReconciliation,
)),
Some(PlanGddCompletionBlockerKind::SubmissionNotStarted)
| Some(PlanGddCompletionBlockerKind::ReceiptAnchorCleanupPending) => None,
}
}
pub(in crate::agent) fn autonomous_registered_derived_visuals_need_repair_at(root: &Path) -> bool {
let Ok(manifest) = read_manifest_for_project(root) else {
return false;
@@ -1889,6 +1970,9 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
}
if plan.actions.is_empty() {
// blocked 的 plan_gdd blocker 有三种截然不同的继续推进态,phase 与
// next_step 必须按类型化子状态选,不能回去猜 detail 字符串。
let mut plan_gdd_blocker_kind: Option<PlanGddCompletionBlockerKind> = None;
let completion_blocker = structured_plan_completion_blocker(&runtime)
.or_else(|| {
provider_action_batch_completion_blocker_at_locked(
@@ -1898,7 +1982,11 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
)
})
.or_else(|| {
plan_gdd_completion_blocker_at_locked(&root, &agent_id, &runtime.run_id)
plan_gdd_typed_completion_blocker_at_locked(&root, &agent_id, &runtime.run_id)
.map(|blocker| {
plan_gdd_blocker_kind = Some(blocker.kind);
blocker.observation
})
})
.or_else(|| game_creator_agent_goal_completion_blocker_at_locked(&root, &runtime))
.or_else(|| goal_contract_acceptance_completion_blocker_at_locked(&root, &runtime))
@@ -1948,22 +2036,11 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
runtime.next_step = "先恢复原批次,不能请求新计划或提交最终回复".to_string();
} else if blocker.tool == "runtime.plan_gdd" {
runtime.status = "running".to_string();
if blocker.status == "blocked"
&& blocker.detail.as_deref().is_some_and(|detail| {
detail.contains("approvalPending=awaiting_decision")
})
{
runtime.phase = "waiting-for-user-input".to_string();
runtime.current_action = "等待 Fast GDD 审批决定".to_string();
runtime.waiting_on = "用户在审批卡选择批准、修改或退回".to_string();
runtime.next_step = "等待 decide_game_creator_plan_gdd;不得重新提交同一 GDD 或自行创建审批 pending".to_string();
} else {
runtime.phase = "needs-reconciliation".to_string();
runtime.current_action = "Fast GDD 审批投影需要人工核对".to_string();
runtime.waiting_on = "planning pending、receipt、原提交锚点、terminal observation、audit 与 session 的精确身份".to_string();
runtime.next_step =
"先恢复或核对现有 durable 事实,不能请求新 Provider 计划".to_string();
}
let projection = plan_gdd_blocker_runtime_projection(plan_gdd_blocker_kind);
runtime.phase = projection.phase.to_string();
runtime.current_action = projection.current_action.to_string();
runtime.waiting_on = projection.waiting_on.to_string();
runtime.next_step = projection.next_step.to_string();
} else if blocker.tool == "runtime.collaboration_policy" {
runtime.status = "running".to_string();
runtime.phase = "planning".to_string();
@@ -2126,23 +2203,7 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
AgentBackgroundTaskOutcome::WaitingForDelegateReceipts,
))
} else if observation.tool == "runtime.plan_gdd" {
if observation.status == "blocked"
&& detail.contains("approvalPending=awaiting_decision")
{
Some((
"agent.runtime.plan.gdd.waiting",
"waiting-for-user-input",
"Fast GDD 审批等待状态持久化失败",
AgentBackgroundTaskOutcome::WaitingForUserInput,
))
} else {
Some((
"agent.runtime.plan.gdd.reconciliation",
"needs-reconciliation",
"Fast GDD 审批投影需要人工核对",
AgentBackgroundTaskOutcome::NeedsReconciliation,
))
}
plan_gdd_blocker_waiting_kind(plan_gdd_blocker_kind)
} else {
None
}
@@ -4211,3 +4272,78 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
}
}
}
#[cfg(test)]
mod plan_gdd_blocker_projection_tests {
use super::*;
/// 三个 blocked 子状态必须落到各自的 phase。旧的子串判别只认得
/// AwaitingApprovalDecision,另外两个会被打成 needs-reconciliation。
#[test]
fn each_blocked_kind_projects_its_own_phase() {
assert_eq!(
plan_gdd_blocker_runtime_projection(Some(
PlanGddCompletionBlockerKind::SubmissionNotStarted
))
.phase,
"planning"
);
assert_eq!(
plan_gdd_blocker_runtime_projection(Some(
PlanGddCompletionBlockerKind::AwaitingApprovalDecision
))
.phase,
"waiting-for-user-input"
);
assert_eq!(
plan_gdd_blocker_runtime_projection(Some(
PlanGddCompletionBlockerKind::ReceiptAnchorCleanupPending
))
.phase,
"planning"
);
}
/// 判不出 kind 与显式的人工核对一样,保持 fail-closed。
#[test]
fn reconciliation_and_unknown_kind_stay_fail_closed() {
assert_eq!(
plan_gdd_blocker_runtime_projection(Some(
PlanGddCompletionBlockerKind::NeedsReconciliation
))
.phase,
"needs-reconciliation"
);
assert_eq!(
plan_gdd_blocker_runtime_projection(None).phase,
"needs-reconciliation"
);
assert!(matches!(
plan_gdd_blocker_waiting_kind(None),
Some((_, _, _, AgentBackgroundTaskOutcome::NeedsReconciliation))
));
}
/// 继续推进态不产生等待态,本轮后台任务不该在这里终结。
#[test]
fn only_user_decision_and_reconciliation_end_the_background_task() {
assert!(matches!(
plan_gdd_blocker_waiting_kind(Some(
PlanGddCompletionBlockerKind::AwaitingApprovalDecision
)),
Some((_, "waiting-for-user-input", _, _))
));
assert!(matches!(
plan_gdd_blocker_waiting_kind(Some(PlanGddCompletionBlockerKind::NeedsReconciliation)),
Some((_, "needs-reconciliation", _, _))
));
assert!(plan_gdd_blocker_waiting_kind(Some(
PlanGddCompletionBlockerKind::SubmissionNotStarted
))
.is_none());
assert!(plan_gdd_blocker_waiting_kind(Some(
PlanGddCompletionBlockerKind::ReceiptAnchorCleanupPending
))
.is_none());
}
}
@@ -819,12 +819,79 @@ fn durable_process_session_recovery_exists_at(root: &Path) -> bool {
false
}
/// Fast GDD approval 投影恢复失败时,把 fail-closed 收敛到受影响的那个 run。
///
/// 返回 `Ok(true)` 表示已经把策划根 Supervisor 标成 needs-reconciliation,调用方可以
/// 继续扫描其余 Agent`Ok(false)` 表示当前没有可归属的 run,无法精确收敛,调用方必须
/// 把原错误照旧上抛,保持全局 fail-closed。
fn contain_plan_gdd_approval_recovery_failure_at(root: &Path, error: &str) -> Result<bool, String> {
let Some(_runtime_lock) = try_acquire_game_creator_agent_runtime_task_lock(
root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
)?
else {
return Ok(false);
};
let mut runtime =
read_game_creator_agent_runtime_at(root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID)?.state;
if runtime.run_id.trim().is_empty()
|| matches!(
runtime.phase.as_str(),
"completed" | "cancelled" | "needs-reconciliation"
)
{
return Ok(false);
}
let error = sanitize_agent_runtime_text(error, 500);
runtime.status = "failed".to_string();
runtime.phase = "needs-reconciliation".to_string();
runtime.current_action = "Fast GDD 审批投影恢复需要人工核对".to_string();
runtime.waiting_on = "开发者核对 planning pending、receipt 与原提交锚点".to_string();
runtime.next_step = "修复审批投影后显式恢复该 run".to_string();
runtime.error = Some(error.clone());
runtime.updated_at = unix_timestamp();
append_game_creator_agent_runtime_task(root, &runtime)?;
refresh_game_creator_agent_runtime_task_queue(root, &mut runtime)?;
write_game_creator_agent_runtime_state(root, &runtime)?;
let _ = append_game_creator_agent_runtime_event(
root,
&runtime,
"plan.gdd.approval_recovery.needs_reconciliation",
"failed",
"needs-reconciliation",
"Fast GDD 审批投影恢复已停止自动重放,等待开发者核对。",
Some(&error),
);
let _ = append_agent_db_record(
root,
serde_json::json!({
"recordType": "agent.runtime.plan.gdd.approval_recovery.needs_reconciliation",
"agentId": runtime.agent_id,
"taskId": runtime.task_id,
"sessionId": runtime.session_id,
"runId": runtime.run_id,
"error": error,
}),
);
emit_game_creator_agent_runtime_update(root, &runtime.agent_id);
Ok(true)
}
pub(in crate::agent) fn resume_game_creator_agent_background_tasks_unredacted_at(
root: &Path,
) -> Result<Vec<AgentRuntimeResult>, String> {
validate_project_root(root)?;
reconcile_plan_gdd_approval_projections_at(root)
.map_err(|error| format!("恢复 GDD approval 投影失败:{error}"))?;
// 这是一条只覆盖策划根 Supervisor 的窄投影恢复,却挂在整轮 resume 的最前面。
// 原来用 `?` 强传播:一次 Fast GDD 投影失败会掐掉全项目所有 Agent 的恢复——而它
// 本身正是 receipt 投影失败后的重试入口,掐掉它等于连兜底一起废掉。改成把
// fail-closed 精确收敛到受影响的那个 run,其余 Agent 照常恢复;实在无法归属时
// 才退回原来的全局上抛。
if let Err(error) = reconcile_plan_gdd_approval_projections_at(root) {
let error = format!("恢复 GDD approval 投影失败:{error}");
if !contain_plan_gdd_approval_recovery_failure_at(root, &error)? {
return Err(error);
}
}
if external_agent_runner_owns_background_execution() {
resume_external_agent_runner(root)?;
return read_game_creator_agent_runtimes_at(root);
@@ -1842,4 +1909,62 @@ mod plan_gdd_approval_wait_recovery_tests {
"status 写成 waiting-for-user-input 才是恢复扫描让路的外部输入等待"
);
}
/// Fast GDD approval 投影恢复失败,不能再掐掉整轮 resume。
///
/// 它挂在 `resume_game_creator_agent_background_tasks_unredacted_at` 的第一行,
/// 原来用 `?` 强传播;而这条 reconcile 本身正是 receipt 投影失败后的重试入口,
/// 掐掉它等于连兜底一起废掉。现在 fail-closed 精确收敛到策划根 Supervisor 这个 run。
#[test]
fn failed_plan_gdd_approval_recovery_contains_itself_instead_of_aborting_the_scan() {
let temporary = crate::tests::canonical_test_tempdir("plan-gdd-approval-recovery-contain-");
let root = temporary.path();
init_local_game_project_at(root, "plan-gdd-approval-contain", "审批投影恢复失败收敛")
.expect("init project");
let runtime = start_game_creator_agent_runtime_task_at(
root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
"推进立项策划",
"plan-gdd-approval-contain-run",
"agent-ready-task-scheduler",
"准备推进立项策划",
vec!["推进立项策划".to_string()],
)
.expect("start plan-root supervisor runtime");
let planning_directory = root.join(".agent/planning");
fs::create_dir_all(&planning_directory).expect("create planning directory");
fs::write(planning_directory.join("gdd.v1.json"), b"{")
.expect("corrupt the GDD lineage so approval recovery fails");
assert!(reconcile_plan_gdd_approval_projections_at(root).is_err());
let resumed = resume_game_creator_agent_background_tasks_at(root)
.expect("窄投影恢复失败不能把整轮 resume 掐掉");
assert!(resumed
.iter()
.all(|result| result.state.phase != "completed"));
let contained = read_game_creator_agent_runtime_at(root, &runtime.agent_id)
.expect("read contained supervisor runtime");
assert_eq!(contained.state.phase, "needs-reconciliation");
assert!(
contained
.state
.error
.as_deref()
.is_some_and(|error| error.contains("恢复 GDD approval 投影失败")),
"unexpected error: {:?}",
contained.state.error
);
let audit_count = read_agent_db_records_bounded(root, 1024 * 1024)
.expect("read approval recovery audit")
.0
.iter()
.filter(|record| {
record.get("recordType").and_then(|value| value.as_str())
== Some("agent.runtime.plan.gdd.approval_recovery.needs_reconciliation")
})
.count();
assert_eq!(audit_count, 1);
}
}
@@ -1288,16 +1288,69 @@ pub(crate) fn decide_plan_gdd_at(
const PLAN_GDD_COMPLETION_BLOCKER_TOOL: &str = "runtime.plan_gdd";
/// 为什么 `runtime.plan_gdd` 的 blocker 需要一个类型化的子状态:
///
/// `status == "needs-reconciliation"` 已经把「要人工核对」和「继续推进」分开了,
/// 但 `"blocked"` 一侧有三种彼此完全不同的继续推进态,驱动侧必须区分才能选对
/// phase 与 next_step。原来的做法是在 `main_loop` 里对 detail 做
/// `contains("approvalPending=awaiting_decision")`:三个 blocked 里只有一个含这个
/// 子串,另外两个会掉进 else 被打成 needs-reconciliation——把最正常的早期推进态和
/// 收尾态当成故障停掉。子状态判别必须由构造方给出,不能让消费方去猜字符串。
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum PlanGddCompletionBlockerKind {
/// 根 Run 还没提交 Fast GDD,下一步是 `agent.delegate`。
SubmissionNotStarted,
/// Fast GDD 已提交,等待用户在审批卡上做决定。
AwaitingApprovalDecision,
/// receipt 已落盘,原 `plan.submit_gdd` 恢复锚点还没清理完。
ReceiptAnchorCleanupPending,
/// 其余一律要人工核对。
NeedsReconciliation,
}
pub(crate) struct PlanGddCompletionBlocker {
pub(crate) observation: AgentRuntimeToolObservation,
pub(crate) kind: PlanGddCompletionBlockerKind,
}
fn plan_gdd_completion_blocker(
status: &str,
summary: impl Into<String>,
detail: impl Into<String>,
) -> AgentRuntimeToolObservation {
AgentRuntimeToolObservation {
tool: PLAN_GDD_COMPLETION_BLOCKER_TOOL.to_string(),
status: status.to_string(),
summary: summary.into(),
detail: Some(detail.into()),
) -> PlanGddCompletionBlocker {
debug_assert_ne!(
status, "blocked",
"blocked 子状态必须走 plan_gdd_blocked_completion_blocker 显式给出 kind"
);
PlanGddCompletionBlocker {
observation: AgentRuntimeToolObservation {
tool: PLAN_GDD_COMPLETION_BLOCKER_TOOL.to_string(),
status: status.to_string(),
summary: summary.into(),
detail: Some(detail.into()),
},
kind: PlanGddCompletionBlockerKind::NeedsReconciliation,
}
}
fn plan_gdd_blocked_completion_blocker(
kind: PlanGddCompletionBlockerKind,
summary: impl Into<String>,
detail: impl Into<String>,
) -> PlanGddCompletionBlocker {
debug_assert_ne!(
kind,
PlanGddCompletionBlockerKind::NeedsReconciliation,
"blocked blocker 不能声明成人工核对"
);
PlanGddCompletionBlocker {
observation: AgentRuntimeToolObservation {
tool: PLAN_GDD_COMPLETION_BLOCKER_TOOL.to_string(),
status: "blocked".to_string(),
summary: summary.into(),
detail: Some(detail.into()),
},
kind,
}
}
@@ -1585,11 +1638,22 @@ fn plan_root_completion_identity_at(
/// committed GDD therefore remains blocked until the pending/receipt,
/// generic child anchors, terminal observation, decision audit and planning
/// session all form one exact durable state.
/// 只要 blocker 本身,不关心 blocked 的子状态。驱动侧(`main_loop`)必须改用
/// `plan_gdd_typed_completion_blocker_at_locked`,否则又要去猜 detail 字符串。
pub(crate) fn plan_gdd_completion_blocker_at_locked(
root: &Path,
agent_id: &str,
run_id: &str,
) -> Option<AgentRuntimeToolObservation> {
plan_gdd_typed_completion_blocker_at_locked(root, agent_id, run_id)
.map(|blocker| blocker.observation)
}
pub(crate) fn plan_gdd_typed_completion_blocker_at_locked(
root: &Path,
agent_id: &str,
run_id: &str,
) -> Option<PlanGddCompletionBlocker> {
let is_plan_root = match plan_root_completion_identity_at(root, agent_id, run_id) {
Ok(value) => value,
Err(error) => {
@@ -1629,8 +1693,8 @@ pub(crate) fn plan_gdd_completion_blocker_at_locked(
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
run_id,
) {
Ok(Some(_)) => Some(plan_gdd_completion_blocker(
"blocked",
Ok(Some(_)) => Some(plan_gdd_blocked_completion_blocker(
PlanGddCompletionBlockerKind::SubmissionNotStarted,
"当前立项策划根 Run 尚未提交 Fast GDD,不能收束任务",
format!(
"rootRunId={run_id} · nextRequiredAction=agent.delegate;上一根 Run 遗留的 game/fast_gdd.md 或 Acceptance Graph 不能代替本根提交"
@@ -1818,8 +1882,8 @@ pub(crate) fn plan_gdd_completion_blocker_at_locked(
),
));
}
return Some(plan_gdd_completion_blocker(
"blocked",
return Some(plan_gdd_blocked_completion_blocker(
PlanGddCompletionBlockerKind::AwaitingApprovalDecision,
"Fast GDD 已提交,等待用户审批决定,不能收束任务",
format!(
"gddVersion={} · approvalPending=awaiting_decision · childPending={} · childBatch={};pending 只能由验收取证通过后的 acceptance-gate caller 创建",
@@ -1852,8 +1916,8 @@ pub(crate) fn plan_gdd_completion_blocker_at_locked(
}
}
if pending_anchor != PlanGddAnchorState::Absent || batch_anchor != PlanGddAnchorState::Absent {
return Some(plan_gdd_completion_blocker(
"blocked",
return Some(plan_gdd_blocked_completion_blocker(
PlanGddCompletionBlockerKind::ReceiptAnchorCleanupPending,
"Fast GDD receipt 已提交,但原 plan.submit_gdd 恢复锚点尚未清理",
format!(
"gddVersion={} · terminalObservation={} · childPending={} · childBatch={}",
@@ -3267,6 +3267,17 @@ mod tests {
.expect("awaiting approval must block completion");
assert_eq!(blocker.status, "blocked");
assert!(blocker.summary.contains("等待用户审批"));
// 驱动侧按类型化子状态选 phase:这一条必须是「等用户决定」,不能被当成人工核对。
assert_eq!(
plan_gdd_typed_completion_blocker_at_locked(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&context.root_run_id,
)
.expect("awaiting approval blocker")
.kind,
PlanGddCompletionBlockerKind::AwaitingApprovalDecision
);
let decision_input = approval_input(
&gdd,
@@ -3377,6 +3388,18 @@ mod tests {
.expect("current root without submission must block");
assert_eq!(blocker.status, "blocked");
assert!(blocker.summary.contains("尚未提交 Fast GDD"));
// 这是策划最正常的早期推进态,下一步是 agent.delegate。它的 detail 里没有
// approvalPending 字段,旧的子串判别会把它打成 needs-reconciliation 停掉整条 run。
assert_eq!(
plan_gdd_typed_completion_blocker_at_locked(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&context.root_run_id,
)
.expect("submission-not-started blocker")
.kind,
PlanGddCompletionBlockerKind::SubmissionNotStarted
);
cleanup_fixture(root);
}
@@ -3421,6 +3444,16 @@ mod tests {
"unexpected blocker: {}",
blocker.summary
);
assert_eq!(
plan_gdd_typed_completion_blocker_at_locked(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&context.root_run_id,
)
.expect("mismatched pending blocker")
.kind,
PlanGddCompletionBlockerKind::NeedsReconciliation
);
cleanup_fixture(root);
}