M1C-1:落地 Fast GDD 审批闭环与完成门

新增 plan-gdd approval receipt、pending、审批命令及三动作幂等投影。

接入 receipt 恢复、generic submit 锚点精确消费与 terminal observation 完整性校验。

接入 exact plan-root completion blocker,并补充 pending、recovery、作用域和 identity 回归。

同步 Fast GDD 技术方案与项目决策记录。
This commit is contained in:
2026-08-15 12:00:57 +00:00
parent 23559b1b1c
commit f45e90e36b
16 changed files with 3763 additions and 56 deletions
@@ -44,6 +44,7 @@ pub(in crate::agent) use run_status_observation::*;
pub(in crate::agent) use structured_plan::*;
pub(in crate::agent) use tool_plan_protocol::*;
pub(crate) use crate::agent::runtime_protocol::plan_gdd_completion_blocker_at_locked;
#[cfg(test)]
pub(crate) use action_audit::agent_runtime_action_receipt_public_safe_detail_for_test;
#[cfg(test)]
@@ -575,6 +575,7 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_plan_liveness_at(
let barrier = static_delegate_completion_barrier_at(root, agent_id, run_id)?;
if barrier.ready_unclaimed_count > 0
|| barrier.unobserved_claim_count > 0
|| barrier.user_revision_pending_count > 0
|| barrier.unknown_contract_status_count > 0
{
let active_delegations =
@@ -583,10 +584,11 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_plan_liveness_at(
return Ok(());
}
return Err(format!(
"{AGENT_RUNTIME_AUTONOMOUS_SUPERVISOR_DELIVERY_CONVERGENCE_LIVENESS_ERROR_PREFIX};当前父 run 在准备专业 repair 前仍有 activeDelegations={active_delegations}、readyUnclaimedReceipts={}、unobservedReceiptClaims={}、repairRequired={}、unknownContractStatus={}。必须先只调用 agent.run_statusagentId=null、scope=all、delegationId=null)原子认领并观察已有回执,再基于权威合同准备 repair",
"{AGENT_RUNTIME_AUTONOMOUS_SUPERVISOR_DELIVERY_CONVERGENCE_LIVENESS_ERROR_PREFIX};当前父 run 在准备专业 repair 前仍有 activeDelegations={active_delegations}、readyUnclaimedReceipts={}、unobservedReceiptClaims={}、repairRequired={}userRevisionPending={}unknownContractStatus={}。必须先只调用 agent.run_statusagentId=null、scope=all、delegationId=null)原子认领并观察已有回执,再基于权威合同准备 repair",
barrier.ready_unclaimed_count,
barrier.unobserved_claim_count,
barrier.repair_required_count,
barrier.user_revision_pending_count,
barrier.unknown_contract_status_count,
));
}
@@ -601,6 +603,7 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_plan_liveness_at(
let active_delegations = active_static_delegate_delivery_count_at(root, agent_id, run_id)?;
let must_claim_or_wait = barrier.ready_unclaimed_count > 0
|| barrier.unobserved_claim_count > 0
|| barrier.user_revision_pending_count > 0
|| barrier.unknown_contract_status_count > 0
|| active_delegations >= 3;
if must_claim_or_wait {
@@ -608,10 +611,11 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_plan_liveness_at(
return Ok(());
}
return Err(format!(
"{AGENT_RUNTIME_AUTONOMOUS_SUPERVISOR_DELIVERY_CONVERGENCE_LIVENESS_ERROR_PREFIX};当前父 run 的 activeDelegations={active_delegations}、waitingDelegations={}、readyUnclaimedReceipts={}、unobservedReceiptClaims={}、unknownContractStatus={}。必须先只调用 agent.run_statusagentId=null、scope=all、delegationId=null)认领并观察 ready delivery,或等待已有委派推进;不得创建第四次 agent.delegate。收束后若项目 revision 已推进,再验证当前 revision",
"{AGENT_RUNTIME_AUTONOMOUS_SUPERVISOR_DELIVERY_CONVERGENCE_LIVENESS_ERROR_PREFIX};当前父 run 的 activeDelegations={active_delegations}、waitingDelegations={}、readyUnclaimedReceipts={}、unobservedReceiptClaims={}userRevisionPending={}unknownContractStatus={}。必须先只调用 agent.run_statusagentId=null、scope=all、delegationId=null)认领并观察 ready delivery,或等待已有委派推进;不得创建第四次 agent.delegate。收束后若项目 revision 已推进,再验证当前 revision",
barrier.waiting_count,
barrier.ready_unclaimed_count,
barrier.unobserved_claim_count,
barrier.user_revision_pending_count,
barrier.unknown_contract_status_count,
));
}
@@ -893,7 +893,12 @@ pub(in crate::agent) fn static_delegate_barrier_has_waiting_deliveries(detail: &
.find_map(|part| part.strip_prefix("unknownContractStatus="))
.and_then(|value| value.parse::<usize>().ok())
.is_some_and(|count| count > 0);
waiting || unknown_contract_status
let user_revision_pending = detail
.split_whitespace()
.find_map(|part| part.strip_prefix("userRevisionPending="))
.and_then(|value| value.parse::<usize>().ok())
.is_some_and(|count| count > 0);
waiting || user_revision_pending || unknown_contract_status
}
pub(in crate::agent) fn static_delegate_barrier_requires_repair(detail: &str) -> bool {
@@ -904,6 +909,14 @@ pub(in crate::agent) fn static_delegate_barrier_requires_repair(detail: &str) ->
.is_some_and(|count| count > 0)
}
pub(in crate::agent) fn static_delegate_barrier_requires_user_revision(detail: &str) -> bool {
detail
.split_whitespace()
.find_map(|part| part.strip_prefix("userRevisionPending="))
.and_then(|value| value.parse::<usize>().ok())
.is_some_and(|count| count > 0)
}
pub(in crate::agent) fn process_session_completion_blocker_at_locked(
root: &Path,
agent_id: &str,
@@ -948,6 +961,7 @@ pub(in crate::agent) fn agent_runtime_non_verification_completion_blocker_at_loc
run_id: &str,
) -> Option<AgentRuntimeToolObservation> {
provider_retry_completion_blocker_at_locked(root, agent_id, run_id)
.or_else(|| plan_gdd_completion_blocker_at_locked(root, agent_id, run_id))
.or_else(|| provider_action_batch_completion_blocker_at_locked(root, agent_id, run_id))
.or_else(|| {
supervisor_collaboration_policy_completion_blocker_at_locked(root, agent_id, run_id)
@@ -1897,6 +1897,9 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
&runtime.run_id,
)
})
.or_else(|| {
plan_gdd_completion_blocker_at_locked(&root, &agent_id, &runtime.run_id)
})
.or_else(|| game_creator_agent_goal_completion_blocker_at_locked(&root, &runtime))
.or_else(|| goal_contract_acceptance_completion_blocker_at_locked(&root, &runtime))
.or_else(|| {
@@ -1943,6 +1946,24 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
runtime.current_action = "等待 Provider action 批次收束".to_string();
runtime.waiting_on = "持久批次完成确认、执行、投影与 cursor 清理".to_string();
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();
}
} else if blocker.tool == "runtime.collaboration_policy" {
runtime.status = "running".to_string();
runtime.phase = "planning".to_string();
@@ -1977,6 +1998,10 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
.detail
.as_deref()
.is_some_and(static_delegate_barrier_requires_repair);
let user_revision_pending = blocker
.detail
.as_deref()
.is_some_and(static_delegate_barrier_requires_user_revision);
let user_input_required = blocker.detail.as_deref().is_some_and(|detail| {
detail
.split_whitespace()
@@ -1985,7 +2010,15 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
.is_some_and(|count| count > 0)
});
runtime.status = "running".to_string();
if user_input_required {
if user_revision_pending {
runtime.phase = "planning".to_string();
runtime.current_action =
"等待 Project Supervisor 发起用户修订续跑".to_string();
runtime.waiting_on =
"审批卡修改/退回对应的 UserRevisionRequested delivery".to_string();
runtime.next_step =
"调用 agent.delegate,并把 repairOfDelegationId 指向原 delivery;不得把用户修订计入 repair_depth".to_string();
} else if user_input_required {
let deliveries = match claimed_static_delegate_deliveries_at(
&root,
&runtime.agent_id,
@@ -2092,6 +2125,24 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
"持久化专业 Agent 回执等待状态失败",
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,
))
}
} else {
None
}
@@ -823,6 +823,8 @@ 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}"))?;
if external_agent_runner_owns_background_execution() {
resume_external_agent_runner(root)?;
return read_game_creator_agent_runtimes_at(root);
@@ -8,6 +8,7 @@ mod context_window;
mod finalization;
mod json_sidecar;
mod models;
mod planning_approval;
mod planning_storage;
mod planning_submit;
mod provider_control;
@@ -23,6 +24,7 @@ pub(in crate::agent) use context_bundle::*;
pub(in crate::agent) use finalization::*;
pub(in crate::agent) use json_sidecar::*;
pub(in crate::agent) use models::*;
pub(crate) use planning_approval::*;
pub(crate) use planning_storage::*;
pub(crate) use planning_submit::*;
pub(in crate::agent) use provider_control::*;
File diff suppressed because it is too large Load Diff
@@ -1613,19 +1613,38 @@ fn project_submit_successors_locked(
return true;
}
};
if read_plan_gdd_index_with_recovery_locked(root, &context.created_at_utc).is_err() {
recovery_pending = true;
if let Ok(index) = build_plan_gdd_index(&chain, &context.created_at_utc) {
// A submit replay may be repairing a projection written for the previous
// lineage length. Rebuild from the complete current GDD/receipt facts on
// every committed submit so a newly created vN can never leave a vN-1
// index behind.
let index = match build_plan_gdd_index_for_root_locked(root, &chain, &context.created_at_utc) {
Ok(index) => {
if write_plan_gdd_index_atomic_locked(root, &index).is_err() {
recovery_pending = true;
}
Some(index)
}
}
Err(_) => {
recovery_pending = true;
None
}
};
// Markdown is a projection of the current latest authority, not of the
// action being replayed. Replaying an older submission must never roll
// `game/fast_gdd.md` back over a newer immutable version.
let projection_gdd = chain.last().unwrap_or(gdd);
if let Ok(markdown) = render_plan_fast_gdd_markdown(projection_gdd, "ready_for_approval") {
let projection_status = index
.as_ref()
.and_then(|index| {
index
.status_cache
.versions
.iter()
.find(|status| status.version == projection_gdd.version)
})
.map(|status| status.status.as_str())
.unwrap_or("ready_for_approval");
if let Ok(markdown) = render_plan_fast_gdd_markdown(projection_gdd, projection_status) {
if write_plan_fast_gdd_markdown_atomic_locked(root, &markdown).is_err() {
recovery_pending = true;
}
@@ -1719,6 +1738,7 @@ pub(crate) fn execute_plan_submit_gdd(
validate_durable_child_binding(root, context)?;
let chain = read_plan_gdd_chain_locked(root)?;
let approvals = read_plan_gdd_approvals_locked(root)?;
// 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
@@ -1789,14 +1809,17 @@ pub(crate) fn execute_plan_submit_gdd(
"actionFingerprint 已被其它 submissionId 使用",
));
}
if !chain.is_empty() {
// M1B-2 has no approval receipt writer yet. Consequently every
// existing candidate is still pending; M1C-1 will refine this check
// from receipt facts instead of weakening the submit boundary.
return Err(submit_error(
"PLAN_PENDING_GDD_EXISTS",
"已有已提交 GDDM1B-2 只允许同 submissionId 重放",
));
validate_plan_gdd_approvals_against_gdds(&chain, &approvals)?;
if let Some(latest) = chain.last() {
if !approvals
.iter()
.any(|receipt| receipt.version == latest.version)
{
return Err(submit_error(
"PLAN_PENDING_GDD_EXISTS",
"最新 GDD 尚未完成用户审批;只能重放原 submissionId",
));
}
}
let Some(current_session) = current_session else {
@@ -1807,13 +1830,23 @@ pub(crate) fn execute_plan_submit_gdd(
};
validate_plan_session(current_session)?;
validate_current_session_cas(current_session, context, input)?;
let version = 1;
let version = chain
.last()
.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 版本",
));
}
let approval_request_id = context
.approval_request_id
.clone()
.unwrap_or_else(generated_approval_request_id);
let candidate =
build_plan_gdd_from_submit_input(input, context, version, &approval_request_id)?;
validate_next_plan_gdd_version(&chain, &candidate)?;
let bytes = canonical_plan_gdd_bytes(&candidate)?;
// Durable create is the submit point. Everything below is best-effort
@@ -2073,6 +2106,16 @@ mod tests {
None,
)
.expect("bind plan root");
start_game_creator_agent_runtime_task_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
"收敛 Fast GDD",
"run-root-001",
AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE,
"等待 project-planning 提交 GDD",
vec!["等待策划子 Run 提交 Fast GDD".to_string()],
)
.expect("start plan root task");
let child_binding = bind_game_creator_agent_runtime_run_profile_at(
&root,
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
@@ -2960,6 +3003,353 @@ mod tests {
cleanup_fixture(root);
}
fn approval_input(
gdd: &PlanGddV1,
action: &str,
response_id: &str,
comment: Option<String>,
) -> DecidePlanGddInputV1 {
DecidePlanGddInputV1 {
gdd_id: gdd.gdd_id.clone(),
version: gdd.version,
fingerprint: gdd.fingerprint.clone(),
pending_action_id: gdd.submission_id.clone(),
approval_request_id: gdd.approval_request_id.clone(),
response_id: response_id.to_string(),
action: action.to_string(),
comment,
}
}
#[test]
fn approval_pending_is_single_latest_unreceipted_projection() {
let (root, context, input) = submit_fixture();
execute_plan_submit_gdd(&root, &context, &input).expect("submit v1");
let gdd = read_plan_gdd_chain(&root)
.expect("read submitted GDD")
.pop()
.expect("GDD exists");
create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending");
let pending = read_plan_gdd_approval_pending(&root)
.expect("read approval pending")
.expect("pending exists");
assert_eq!(pending.status, "awaiting_decision");
// Recreating the exact card is an idempotent replay.
create_plan_gdd_approval_pending_at(&root, &gdd).expect("replay approval pending");
let mut forged_next = gdd.clone();
forged_next.version = 2;
forged_next.submission_id = "action-fedcba9876543210fedcba98".to_string();
forged_next.approval_request_id =
"gdd-approval-00000000-0000-4000-8000-000000000003".to_string();
forged_next.action_fingerprint = "4".repeat(64);
forged_next.fingerprint = plan_gdd_fingerprint(&forged_next).expect("next fingerprint");
let stale = create_plan_gdd_approval_pending_at(&root, &forged_next)
.expect_err("a non-latest GDD cannot receive an approval card");
assert_eq!(stale.code(), "PLAN_STALE_APPROVAL");
let decision = decide_plan_gdd_at(
&root,
&approval_input(
&gdd,
"approve",
"gdd-response-00000000-0000-4000-8000-000000000010",
None,
),
)
.expect("commit approval receipt");
assert_eq!(decision.outcome, "committed");
let after_receipt = create_plan_gdd_approval_pending_at(&root, &gdd)
.expect_err("a receipt must close awaiting_decision recreation");
assert_eq!(after_receipt.code(), "PLAN_STALE_APPROVAL");
cleanup_fixture(root);
}
#[test]
fn plan_gdd_completion_blocker_requires_pending_and_terminal_observation() {
let (root, context, input) = submit_fixture();
execute_plan_submit_gdd(&root, &context, &input).expect("submit v1");
let gdd = read_plan_gdd_chain(&root)
.expect("read submitted GDD")
.pop()
.expect("GDD exists");
let blocker = plan_gdd_completion_blocker_at_locked(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&context.root_run_id,
)
.expect("missing approval pending must block reconciliation");
assert_eq!(blocker.status, "needs-reconciliation");
assert!(blocker.summary.contains("审批 pending"));
create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending");
let blocker = plan_gdd_completion_blocker_at_locked(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&context.root_run_id,
)
.expect("awaiting approval must block completion");
assert_eq!(blocker.status, "blocked");
assert!(blocker.summary.contains("等待用户审批"));
let decision_input = approval_input(
&gdd,
"approve",
"gdd-response-00000000-0000-4000-8000-000000000030",
None,
);
let decision = decide_plan_gdd_at(&root, &decision_input).expect("commit receipt");
assert_eq!(decision.outcome, "committed");
assert!(decision.recovery_pending);
let blocker = plan_gdd_completion_blocker_at_locked(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&context.root_run_id,
)
.expect("receipt without terminal observation must reconcile");
assert_eq!(blocker.status, "needs-reconciliation");
assert!(blocker.summary.contains("terminal observation"));
append_agent_db_terminal_observation_if_missing_for_action(
&root,
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
&gdd.created_by_run_id,
&gdd.submission_id,
serde_json::json!({
"recordType": "agent.runtime.tool_observation",
"agentId": GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
"taskId": "task-plan-001",
"runId": gdd.created_by_run_id,
"actionId": gdd.submission_id,
"actionFingerprint": gdd.action_fingerprint,
"tool": PLAN_GDD_APPROVAL_TOOL,
"status": "ok",
"summary": "Fast GDD v1 已批准",
"decision": "approval",
}),
)
.expect("append exact terminal observation");
assert!(!reconcile_plan_gdd_approval_projections_at(&root)
.expect("reconcile receipt projections"));
assert!(plan_gdd_completion_blocker_at_locked(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&context.root_run_id,
)
.is_none());
cleanup_fixture(root);
}
#[test]
fn plan_gdd_completion_blocker_is_scoped_to_exact_plan_root() {
let (root, context, input) = submit_fixture();
execute_plan_submit_gdd(&root, &context, &input).expect("submit v1");
assert!(plan_gdd_completion_blocker_at_locked(
&root,
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
"run-child-001",
)
.is_none());
assert!(plan_gdd_completion_blocker_at_locked(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
"run-not-current",
)
.is_none());
cleanup_fixture(root);
}
#[test]
fn plan_gdd_completion_blocker_rejects_mismatched_observed_pending() {
let (root, context, input) = submit_fixture();
execute_plan_submit_gdd(&root, &context, &input).expect("submit v1");
let gdd = read_plan_gdd_chain(&root)
.expect("read submitted GDD")
.pop()
.expect("GDD exists");
create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending");
decide_plan_gdd_at(
&root,
&approval_input(
&gdd,
"approve",
"gdd-response-00000000-0000-4000-8000-000000000031",
None,
),
)
.expect("commit receipt");
let mut pending = read_plan_gdd_approval_pending(&root)
.expect("read observed pending")
.expect("observed pending remains during recovery");
pending.run_identity.run_id = "run-other-001".to_string();
pending.pending_fingerprint =
plan_gdd_approval_pending_fingerprint(&pending).expect("recompute pending fingerprint");
write_plan_gdd_approval_pending_atomic(&root, &pending)
.expect("write mismatched projection");
let blocker = plan_gdd_completion_blocker_at_locked(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&context.root_run_id,
)
.expect("mismatched pending must require reconciliation");
assert_eq!(blocker.status, "needs-reconciliation");
assert!(blocker.summary.contains("pending 尚未按 receipt 收口"));
cleanup_fixture(root);
}
#[test]
fn approval_comment_accepts_full_scalar_limit_and_observation_prefix() {
let (root, context, input) = submit_fixture();
execute_plan_submit_gdd(&root, &context, &input).expect("submit v1");
let gdd = read_plan_gdd_chain(&root)
.expect("read submitted GDD")
.pop()
.expect("GDD exists");
let comment = "".repeat(1_000);
let normalized = normalize_plan_gdd_approval_comment("revise", Some(&comment))
.expect("1,000 scalar comment is valid")
.expect("comment remains present");
assert_eq!(normalized.chars().count(), 1_000);
let mut pending = PlanGddApprovalPendingV1 {
schema_version: PLAN_GDD_APPROVAL_PENDING_SCHEMA_VERSION.to_string(),
kind: PLAN_GDD_APPROVAL_PENDING_KIND.to_string(),
project_id: gdd.project_id.clone(),
agent_id: PLAN_GDD_APPROVAL_AGENT_ID.to_string(),
gdd_ref: PlanGddRef {
gdd_id: gdd.gdd_id.clone(),
version: gdd.version,
fingerprint: gdd.fingerprint.clone(),
},
submission: PlanGddApprovalPendingSubmission {
tool: PLAN_GDD_APPROVAL_TOOL.to_string(),
pending_action_id: gdd.submission_id.clone(),
action_fingerprint: gdd.action_fingerprint.clone(),
approval_request_id: gdd.approval_request_id.clone(),
},
run_identity: PlanGddApprovalPendingRunIdentity {
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(),
},
status: "observed_revise".to_string(),
observation: Some(PlanGddApprovalObservationV1 {
tool: PLAN_GDD_APPROVAL_TOOL.to_string(),
status: "ok".to_string(),
summary: format!("Fast GDD v{} 需要修改", gdd.version),
detail: Some(format!("用户修改意见:{normalized}")),
}),
pending_fingerprint: String::new(),
};
pending.pending_fingerprint =
plan_gdd_approval_pending_fingerprint(&pending).expect("pending fingerprint");
validate_plan_gdd_approval_pending(&pending)
.expect("observation prefix must leave room for the full comment");
let mut tampered = pending.clone();
tampered
.observation
.as_mut()
.expect("observation exists")
.summary = "伪造的审批摘要".to_string();
tampered.pending_fingerprint =
plan_gdd_approval_pending_fingerprint(&tampered).expect("tampered fingerprint");
assert!(validate_plan_gdd_approval_pending(&tampered).is_err());
let too_long = format!("{comment}");
assert!(normalize_plan_gdd_approval_comment("revise", Some(&too_long)).is_err());
let decision_error = decide_plan_gdd_at(
&root,
&approval_input(
&gdd,
"revise",
"gdd-response-00000000-0000-4000-8000-000000000011",
Some(too_long),
),
)
.expect_err("invalid command comment must be rejected at transport boundary");
assert_eq!(decision_error.code(), "PLAN_INVALID_REQUEST");
assert_eq!(
PLAN_GDD_APPROVAL_FINGERPRINT_DOMAIN,
"genarrative.plan.gdd-approval-receipt.v1"
);
cleanup_fixture(root);
}
#[test]
fn approval_actions_rebuild_receipt_aware_index_and_are_idempotent() {
for (action, comment, expected_status) in [
("approve", None, "approved"),
("revise", Some("请收窄首局范围"), "revision_requested"),
("reject", Some("当前方向需要重新梳理"), "rejected"),
] {
let (root, context, input) = submit_fixture();
execute_plan_submit_gdd(&root, &context, &input).expect("submit v1");
let gdd = read_plan_gdd_chain(&root)
.expect("read submitted GDD")
.pop()
.expect("GDD exists");
create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending");
let first_input = approval_input(
&gdd,
action,
"gdd-response-00000000-0000-4000-8000-000000000020",
comment.map(str::to_string),
);
let first = decide_plan_gdd_at(&root, &first_input).expect("commit receipt");
assert_eq!(first.outcome, "committed");
assert_eq!(first.decision_ref.action, action);
assert_eq!(first.approved_gdd_ref.is_some(), action == "approve");
let approvals = read_plan_gdd_approvals(&root).expect("read receipts");
let index = build_plan_gdd_index_with_approvals(
&read_plan_gdd_chain(&root).expect("read GDD chain"),
&approvals,
"2026-08-15T00:00:00.000Z",
)
.expect("receipt-aware index");
assert_eq!(index.status_cache.versions[0].status, expected_status);
assert_eq!(index.status_cache.pending_version, None);
assert_eq!(
index.status_cache.approved_version,
(action == "approve").then_some(1)
);
let replay = decide_plan_gdd_at(&root, &first_input).expect("replay same response");
assert_eq!(replay.outcome, "replayed");
let already_decided = decide_plan_gdd_at(
&root,
&approval_input(
&gdd,
action,
"gdd-response-00000000-0000-4000-8000-000000000021",
comment.map(str::to_string),
),
)
.expect("different response returns existing decision");
assert_eq!(already_decided.outcome, "already-decided");
let conflicting = decide_plan_gdd_at(
&root,
&approval_input(
&gdd,
action,
"gdd-response-00000000-0000-4000-8000-000000000020",
if action == "approve" {
Some("changed intent".to_string())
} else {
Some("changed intent".to_string())
},
),
)
.expect_err("same response with changed intent must conflict");
assert_eq!(conflicting.code(), "PLAN_DECISION_IDENTITY_CONFLICT");
cleanup_fixture(root);
}
}
#[test]
fn projection_failure_after_gdd_create_returns_recovery_pending_and_replays() {
let (root, context, input) = submit_fixture();
@@ -337,6 +337,11 @@ pub(in crate::agent) fn wake_waiting_static_delegate_parent_run_at(
ensure_static_delegate_user_input_wait_at(root, &mut state, &deliveries)?;
return Ok(true);
}
if barrier.user_revision_pending_count > 0 {
// A user revision is an explicit Supervisor decision boundary. Do
// not auto-resume the parent before it dispatches the continuation.
return Ok(false);
}
let state = advance_game_creator_agent_runtime_turn_at(
root,
state,
@@ -1206,6 +1206,48 @@ pub(crate) fn answer_game_creator_agent_runtime_user_input(
)
}
#[tauri::command]
pub(crate) fn decide_game_creator_plan_gdd(
project_path: String,
gdd_id: String,
version: u32,
fingerprint: String,
pending_action_id: String,
approval_request_id: String,
response_id: String,
action: String,
comment: Option<String>,
) -> Result<PlanGddDecisionResultV1, String> {
let root = validated_local_project_directory_path(project_path.trim())?;
enforce_project_permission_policy(&root, "conversation.read")?;
enforce_project_permission_policy(&root, "conversation.write")?;
enforce_project_permission_policy(&root, "agent.run_status")?;
enforce_project_permission_policy(&root, "agent.resume")?;
let mut result = decide_plan_gdd_at(
&root,
&DecidePlanGddInputV1 {
gdd_id,
version,
fingerprint,
pending_action_id,
approval_request_id,
response_id,
action,
comment,
},
)
.map_err(|error| error.to_string())?;
if !result.recovery_pending {
if wake_pending_game_creator_agent_background_tasks_at(&root).is_err() {
// The receipt is already the user-decision linearization point;
// surface a recoverable projection state instead of turning a
// durable approval into a false command failure.
result.recovery_pending = true;
}
}
Ok(result)
}
#[tauri::command]
pub(crate) fn read_game_creator_agent_runtime(
project_path: String,
@@ -216,6 +216,7 @@ pub(crate) struct StaticDelegateCompletionBarrier {
pub(crate) unobserved_claim_count: usize,
pub(crate) repair_required_count: usize,
pub(crate) user_input_required_count: usize,
pub(crate) user_revision_pending_count: usize,
pub(crate) unknown_contract_status_count: usize,
}
@@ -226,21 +227,25 @@ impl StaticDelegateCompletionBarrier {
&& self.unobserved_claim_count == 0
&& self.repair_required_count == 0
&& self.user_input_required_count == 0
&& self.user_revision_pending_count == 0
&& self.unknown_contract_status_count == 0
}
pub(crate) fn has_waiting(self) -> bool {
self.waiting_count > 0 || self.unknown_contract_status_count > 0
self.waiting_count > 0
|| self.user_revision_pending_count > 0
|| self.unknown_contract_status_count > 0
}
pub(crate) fn detail(self) -> String {
format!(
"waitingDelegations={} · readyUnclaimedReceipts={} · unobservedReceiptClaims={} · repairRequired={} · userInputRequired={} · unknownContractStatus={} · 必须认领专业 Agent 回执,处理 needs-user-input/needs-repair,或升级客户端后再继续",
"waitingDelegations={} · readyUnclaimedReceipts={} · unobservedReceiptClaims={} · repairRequired={} · userInputRequired={} · userRevisionPending={} · unknownContractStatus={} · 必须认领专业 Agent 回执,处理 needs-user-input/needs-repair/user-revision-requested,或升级客户端后再继续",
self.waiting_count,
self.ready_unclaimed_count,
self.unobserved_claim_count,
self.repair_required_count,
self.user_input_required_count,
self.user_revision_pending_count,
self.unknown_contract_status_count
)
}
@@ -445,6 +450,42 @@ pub(crate) fn mark_static_delegate_delivery_ready_with_result_at(
Ok(delivery)
}
/// Mark an already claimed, evidence-ready planning delivery as waiting for a
/// user-requested revision. Approval is the only producer of this durable
/// status; keeping the transition here makes its evidence precondition and
/// idempotency explicit instead of allowing a generic delivery writer to
/// manufacture the state.
pub(crate) fn mark_static_delegate_delivery_user_revision_requested_at(
root: &Path,
parent_agent_id: &str,
parent_run_id: &str,
delegation_id: &str,
) -> Result<StaticDelegateDeliveryRecord, String> {
validate_static_delegate_id(parent_agent_id, "parentAgentId", 96)?;
validate_static_delegate_id(parent_run_id, "parentRunId", 160)?;
validate_static_delegate_id(delegation_id, "delegationId", 160)?;
let mut delivery = read_static_delegate_delivery_at(root, delegation_id)?
.ok_or_else(|| format!("静态委派 delivery 不存在:{delegation_id}"))?;
if delivery.parent_agent_id != parent_agent_id || delivery.parent_run_id != parent_run_id {
return Err("用户修订只能改写同一 Supervisor 父 run 的 delivery".to_string());
}
if delivery.status != StaticDelegateDeliveryStatus::ClaimedByParent {
return Err("用户修订只能改写已由 Supervisor 认领的 delivery".to_string());
}
let Some(result) = delivery.structured_result.as_mut() else {
return Err("用户修订的原 delivery 缺少 structuredResult".to_string());
};
match result.contract_status {
StaticDelegateContractStatus::UserRevisionRequested => return Ok(delivery),
StaticDelegateContractStatus::EvidenceReady => {}
_ => return Err("用户修订只能从 EvidenceReady delivery 派生".to_string()),
}
result.contract_status = StaticDelegateContractStatus::UserRevisionRequested;
delivery.updated_at = unix_timestamp();
write_static_delegate_delivery_at(root, &delivery)?;
Ok(delivery)
}
pub(crate) fn suppress_static_delegate_delivery_at(
root: &Path,
expected: &StaticDelegateDeliveryRecord,
@@ -558,6 +599,25 @@ pub(crate) fn static_delegate_completion_barrier_at(
})
})
.count();
barrier.user_revision_pending_count = deliveries
.iter()
.filter(|delivery| {
delivery.status == StaticDelegateDeliveryStatus::ClaimedByParent
&& delivery.structured_result.as_ref().is_some_and(|result| {
result.contract_status == StaticDelegateContractStatus::UserRevisionRequested
})
&& !deliveries.iter().any(|candidate| {
candidate.repair_of_delegation_id.as_deref()
== Some(delivery.delegation_id.as_str())
&& matches!(
candidate.status,
StaticDelegateDeliveryStatus::Dispatched
| StaticDelegateDeliveryStatus::Ready
| StaticDelegateDeliveryStatus::ClaimedByParent
)
})
})
.count();
barrier.unknown_contract_status_count = deliveries
.iter()
.filter(|delivery| {
@@ -2281,28 +2341,39 @@ fn validate_static_delegate_structured_result(
if result.verified_revision == Some(0) {
return Err("静态委派 verifiedRevision 必须大于 0".to_string());
}
if result.contract_status == StaticDelegateContractStatus::EvidenceReady
&& (terminal_status != "completed"
|| !result.missing_expected_artifacts.is_empty()
|| (result.verification_required && result.verified_revision.is_none()))
{
return Err("静态委派 evidence-ready 与客观证据冲突".to_string());
match &result.contract_status {
StaticDelegateContractStatus::EvidenceReady
| StaticDelegateContractStatus::UserRevisionRequested => {
if terminal_status != "completed"
|| !result.missing_expected_artifacts.is_empty()
|| (result.verification_required && result.verified_revision.is_none())
{
return Err(
"静态委派 evidence-ready/user-revision-requested 与客观证据冲突".to_string(),
);
}
}
StaticDelegateContractStatus::NeedsUserInput => {
if terminal_status != "completed"
|| result.user_input_questions.is_empty()
|| result.user_input_questions.len() > 3
{
return Err("静态委派 needs-user-input 与终态或问题数量冲突".to_string());
}
let expected_sha = serde_json::to_vec(&result.user_input_questions)
.map(|bytes| format!("{:x}", Sha256::digest(bytes)))
.map_err(|error| format!("序列化静态委派用户问题失败:{error}"))?;
if result.user_input_questions_sha256.as_deref() != Some(expected_sha.as_str()) {
return Err("静态委派 needs-user-input 问题指纹无效".to_string());
}
}
StaticDelegateContractStatus::NeedsRepair | StaticDelegateContractStatus::Unknown(_) => {}
}
if result.contract_status == StaticDelegateContractStatus::NeedsUserInput {
if terminal_status != "completed"
|| result.user_input_questions.is_empty()
|| result.user_input_questions.len() > 3
{
return Err("静态委派 needs-user-input 与终态或问题数量冲突".to_string());
}
let expected_sha = serde_json::to_vec(&result.user_input_questions)
.map(|bytes| format!("{:x}", Sha256::digest(bytes)))
.map_err(|error| format!("序列化静态委派用户问题失败:{error}"))?;
if result.user_input_questions_sha256.as_deref() != Some(expected_sha.as_str()) {
return Err("静态委派 needs-user-input 问题指纹无效".to_string());
}
} else if !result.user_input_questions.is_empty()
|| result.user_input_questions_sha256.is_some()
if !matches!(
result.contract_status,
StaticDelegateContractStatus::NeedsUserInput
) && (!result.user_input_questions.is_empty()
|| result.user_input_questions_sha256.is_some())
{
return Err("非 needs-user-input 静态委派不能携带用户问题".to_string());
}
@@ -2932,6 +3003,145 @@ mod tests {
);
}
#[test]
fn static_delegate_user_revision_barrier_blocks_every_revision_until_continuation() {
let root = std::env::temp_dir().join(format!(
"genarrative-static-user-revision-barrier-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system time after unix epoch")
.as_nanos()
));
init_local_game_project_at(
&root,
"m1c1-user-revision-barrier",
"M1C-1 用户修订 barrier 测试",
)
.expect("project init");
let parent_run_id = "m1c1-user-revision-barrier-parent-run";
let first = claimed_static_delegate_for_lineage_test(
parent_run_id,
"m1c1-user-revision-barrier-first",
None,
StaticDelegateContractStatus::EvidenceReady,
);
write_static_delegate_delivery_at(&root, &first).expect("write first delivery");
mark_static_delegate_delivery_user_revision_requested_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
parent_run_id,
&first.delegation_id,
)
.expect("mark first delivery for user revision");
let first_barrier = static_delegate_completion_barrier_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
parent_run_id,
)
.expect("read first revision barrier");
assert_eq!(first_barrier.user_revision_pending_count, 1);
assert!(!first_barrier.is_clear());
assert!(first_barrier.has_waiting());
assert!(first_barrier.detail().contains("userRevisionPending=1"));
let mut continuation = claimed_static_delegate_for_lineage_test(
parent_run_id,
"m1c1-user-revision-barrier-continuation",
Some(&first.delegation_id),
StaticDelegateContractStatus::EvidenceReady,
);
continuation.status = StaticDelegateDeliveryStatus::Dispatched;
continuation.terminal_status = None;
continuation.result_summary = None;
continuation.structured_result = None;
continuation.claimed_by_action_id = None;
write_static_delegate_delivery_at(&root, &continuation).expect("write continuation");
let active_barrier = static_delegate_completion_barrier_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
parent_run_id,
)
.expect("read active continuation barrier");
assert_eq!(active_barrier.user_revision_pending_count, 0);
assert_eq!(active_barrier.waiting_count, 1);
continuation.status = StaticDelegateDeliveryStatus::ClaimedByParent;
continuation.terminal_status = Some("completed".to_string());
continuation.result_summary = Some("second revision candidate".to_string());
continuation.structured_result = Some(StaticDelegateStructuredResult {
contract_status: StaticDelegateContractStatus::EvidenceReady,
..StaticDelegateStructuredResult::default()
});
continuation.claimed_by_action_id =
Some("m1c1-user-revision-barrier-continuation-claim".to_string());
write_static_delegate_delivery_at(&root, &continuation)
.expect("complete continuation delivery");
assert!(
static_delegate_completion_barrier_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
parent_run_id,
)
.expect("read completed continuation barrier")
.is_clear(),
"the previous revision is satisfied once its continuation is claimed"
);
mark_static_delegate_delivery_user_revision_requested_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
parent_run_id,
&continuation.delegation_id,
)
.expect("mark a repair-node delivery for the second revision");
let second_barrier = static_delegate_completion_barrier_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
parent_run_id,
)
.expect("read second revision barrier");
assert_eq!(
second_barrier.user_revision_pending_count, 1,
"a revision whose parent delivery is itself a repair node must still block"
);
assert!(!second_barrier.is_clear());
fs::remove_dir_all(root).ok();
}
#[test]
fn static_delegate_user_revision_reuses_evidence_ready_objective_constraints() {
let base = StaticDelegateStructuredResult {
contract_status: StaticDelegateContractStatus::UserRevisionRequested,
..StaticDelegateStructuredResult::default()
};
validate_static_delegate_structured_result(&base, "completed", &[])
.expect("completed user revision with complete evidence is valid");
let failed = validate_static_delegate_structured_result(&base, "failed", &[])
.expect_err("failed terminal status must reject a user revision result");
assert!(failed.contains("客观证据冲突"));
let mut missing = base.clone();
missing.missing_expected_artifacts = vec!["game/fast_gdd.md".to_string()];
let missing_error = validate_static_delegate_structured_result(
&missing,
"completed",
&["game/fast_gdd.md".to_string()],
)
.expect_err("missing expected artifact must reject a user revision result");
assert!(missing_error.contains("客观证据冲突"));
let mut unverified = base;
unverified.verification_required = true;
let verification_error =
validate_static_delegate_structured_result(&unverified, "completed", &[])
.expect_err("required verification without a revision must be rejected");
assert!(verification_error.contains("客观证据冲突"));
}
#[test]
fn static_delegate_lineage_boundary_and_status_deserialization_fail_closed() {
let mut deliveries = Vec::new();
@@ -2208,6 +2208,7 @@ fn main() {
confirm_game_creator_agent_runtime_task,
reject_game_creator_agent_runtime_task,
answer_game_creator_agent_runtime_user_input,
decide_game_creator_plan_gdd,
read_game_creator_agent_runtime,
read_game_creator_agent_runtimes,
resume_game_creator_agent_runtime_tasks,
@@ -6,6 +6,8 @@ use super::filesystem::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT;
const AGENT_DB_MAX_RECORD_BYTES: usize = 1024 * 1024;
const AGENT_DB_ACTION_RECEIPT_RECORD_TYPE: &str = "agent.runtime.action_receipt";
const AGENT_DB_PLAN_GDD_DECISION_RECORD_TYPE: &str = "agent.runtime.plan.gdd_decided";
const AGENT_DB_PLAN_GDD_DECISION_AUDIT_SCHEMA_V1: &str = "agent-runtime-plan-gdd-decided.v1";
const AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE: &str =
"agent.runtime.provider_request.lifecycle";
const AGENT_DB_FINALIZATION_LIFECYCLE_RECORD_TYPE: &str = "agent.runtime.finalization.lifecycle";
@@ -32,12 +34,22 @@ const AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_RECORDS: u64 = 128;
const AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_BYTES: u64 =
(AGENT_DB_FINALIZATION_CRITICAL_MAX_RECORD_BYTES as u64 + 1)
* AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_RECORDS;
// Planning decisions are a separate durability lane. They must retain a
// complete 128-version lineage even when ordinary/lifecycle records consume
// the rest of the Agent DB budget, so they cannot share either existing tail.
const AGENT_DB_PLAN_GDD_DECISION_MAX_RECORD_BYTES: usize = 16 * 1024;
const AGENT_DB_PLAN_GDD_DECISION_RESERVE_RECORDS: u64 = 128;
const AGENT_DB_PLAN_GDD_DECISION_RESERVE_BYTES: u64 =
(AGENT_DB_PLAN_GDD_DECISION_MAX_RECORD_BYTES as u64 + 1)
* AGENT_DB_PLAN_GDD_DECISION_RESERVE_RECORDS;
const AGENT_DB_MAX_ORDINARY_APPEND_BYTES: u64 = AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES
- AGENT_DB_TERMINAL_RESERVE_BYTES
- AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_BYTES;
- AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_BYTES
- AGENT_DB_PLAN_GDD_DECISION_RESERVE_BYTES;
const AGENT_DB_MAX_ORDINARY_APPEND_RECORDS: usize = AGENT_DB_MAX_SCAN_RECORDS
- AGENT_DB_TERMINAL_RESERVE_RECORDS as usize
- AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_RECORDS as usize;
- AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_RECORDS as usize
- AGENT_DB_PLAN_GDD_DECISION_RESERVE_RECORDS as usize;
const AGENT_DB_FINALIZATION_CRITICAL_RECORDS_PER_SEQUENCE: usize = 7;
const AGENT_DB_MAX_BOUNDED_READ_BYTES: u64 = 32 * 1024 * 1024;
const AGENT_DB_MAX_BOUNDED_RECORDS: usize = 16_384;
@@ -48,10 +60,14 @@ pub(super) enum AgentDbRecordAppendClass {
ActionTerminal,
LifecycleTerminal,
FinalizationCritical,
PlanGddDecision,
}
pub(super) fn agent_db_record_append_class(record: &serde_json::Value) -> AgentDbRecordAppendClass {
let record_type = record.get("recordType").and_then(serde_json::Value::as_str);
if record_type == Some(AGENT_DB_PLAN_GDD_DECISION_RECORD_TYPE) {
return AgentDbRecordAppendClass::PlanGddDecision;
}
if record_type == Some(AGENT_DB_ACTION_RECEIPT_RECORD_TYPE)
|| agent_db_record_uses_terminal_reserve(record)
{
@@ -956,6 +972,9 @@ pub(crate) fn append_agent_db_record(root: &Path, record: serde_json::Value) ->
Some(AGENT_DB_ACTION_RECEIPT_RECORD_TYPE) => {
return Err("Agent 持久动作回执必须使用幂等终态 receipt 追加入口".to_string())
}
Some(AGENT_DB_PLAN_GDD_DECISION_RECORD_TYPE) => {
return Err("Agent DB planning decision 必须使用专用幂等追加入口".to_string())
}
Some(
AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE
| AGENT_DB_FINALIZATION_LIFECYCLE_RECORD_TYPE,
@@ -1087,6 +1106,14 @@ fn validate_agent_db_append_class_record_size(
AGENT_DB_LIFECYCLE_TERMINAL_MAX_RECORD_BYTES
));
}
if record_type.as_deref() == Some(AGENT_DB_PLAN_GDD_DECISION_RECORD_TYPE)
&& line.len() > AGENT_DB_PLAN_GDD_DECISION_MAX_RECORD_BYTES
{
return Err(format!(
"Agent DB planning decision 单条记录超过 {} 字节上限",
AGENT_DB_PLAN_GDD_DECISION_MAX_RECORD_BYTES
));
}
if append_class == AgentDbRecordAppendClass::FinalizationCritical
&& line.len() > AGENT_DB_FINALIZATION_CRITICAL_MAX_RECORD_BYTES
{
@@ -2264,6 +2291,233 @@ pub(crate) fn append_agent_db_plan_submit_gdd_committed_if_missing_for_action(
)
}
/// Append the dedicated planning-decision audit lane. This intentionally
/// does not reuse action-id idempotency: a response id is scoped by
/// `(gddId, version)` and may be reused on another version, while two windows
/// deciding one version with different intent must fail closed.
pub(crate) fn append_agent_db_plan_gdd_decision_if_missing(
root: &Path,
record: serde_json::Value,
) -> Result<bool, String> {
const RECORD_TYPE: &str = AGENT_DB_PLAN_GDD_DECISION_RECORD_TYPE;
const FIELDS: &[&str] = &[
"recordType",
"auditSchemaVersion",
"projectId",
"agentId",
"gddId",
"version",
"gddFingerprint",
"pendingActionId",
"actionFingerprint",
"approvalRequestId",
"responseId",
"source",
"runProfile",
"runProfileBindingFingerprint",
"sessionId",
"runId",
"action",
"decisionFingerprint",
"commentHash",
"commentLength",
"receiptFingerprint",
"decidedAtUtc",
];
if !agent_db_record_has_exact_payload_fields(&record, FIELDS)
|| record.get("recordType").and_then(serde_json::Value::as_str) != Some(RECORD_TYPE)
|| record
.get("auditSchemaVersion")
.and_then(serde_json::Value::as_str)
!= Some(AGENT_DB_PLAN_GDD_DECISION_AUDIT_SCHEMA_V1)
|| record.get("agentId").and_then(serde_json::Value::as_str) != Some("project-supervisor")
|| record.get("source").and_then(serde_json::Value::as_str)
!= Some("project-supervisor-plan")
|| record.get("runProfile").and_then(serde_json::Value::as_str) != Some("standard")
|| !matches!(
record.get("action").and_then(serde_json::Value::as_str),
Some("approve" | "revise" | "reject")
)
{
return Err("Agent DB planning decision 字段集合或固定身份无效".to_string());
}
let project_id = record
.get("projectId")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let gdd_id = record
.get("gddId")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let version = record
.get("version")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
let gdd_fingerprint = record
.get("gddFingerprint")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let pending_action_id = record
.get("pendingActionId")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let action_fingerprint = record
.get("actionFingerprint")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let approval_request_id = record
.get("approvalRequestId")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let response_id = record
.get("responseId")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let binding_fingerprint = record
.get("runProfileBindingFingerprint")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let session_id = record
.get("sessionId")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let run_id = record
.get("runId")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let decision_fingerprint = record
.get("decisionFingerprint")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let comment_hash = record
.get("commentHash")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let receipt_fingerprint = record
.get("receiptFingerprint")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let comment_length = record
.get("commentLength")
.and_then(serde_json::Value::as_u64)
.unwrap_or(u64::MAX);
let valid_uuid_suffix = |value: &str, prefix: &str| {
value.strip_prefix(prefix).is_some_and(|suffix| {
uuid::Uuid::parse_str(suffix)
.ok()
.is_some_and(|parsed| parsed.hyphenated().to_string() == suffix)
})
};
let valid_typed = |value: &str| {
value
.strip_prefix("sha256-serde-json-v2:")
.is_some_and(|suffix| is_valid_agent_db_sha256(suffix))
};
if project_id.is_empty()
|| !is_safe_agent_db_lifecycle_identity(project_id)
|| !valid_uuid_suffix(gdd_id, "gdd-")
|| !(1..=128).contains(&version)
|| !valid_typed(gdd_fingerprint)
|| !pending_action_id
.strip_prefix("action-")
.is_some_and(|suffix| {
suffix.len() == 24
&& suffix
.bytes()
.all(|byte| (b'a'..=b'f').contains(&byte) || byte.is_ascii_digit())
})
|| !is_valid_agent_db_sha256(action_fingerprint)
|| !valid_uuid_suffix(approval_request_id, "gdd-approval-")
|| !valid_uuid_suffix(response_id, "gdd-response-")
|| !is_valid_agent_db_sha256(binding_fingerprint)
|| !is_safe_agent_db_lifecycle_identity(session_id)
|| !is_safe_agent_db_lifecycle_identity(run_id)
|| !valid_typed(decision_fingerprint)
|| !valid_typed(comment_hash)
|| comment_length > 1_000
|| !valid_typed(receipt_fingerprint)
{
return Err("Agent DB planning decision durable identity/fingerprint 无效".to_string());
}
let decided_at = record
.get("decidedAtUtc")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
crate::agent::validate_timestamp(decided_at, "agent.db.decidedAtUtc")
.map_err(|error| error.to_string())?;
let path = root.join(".agent/agent.db");
let directory = open_agent_db_directory(root, true)?
.ok_or_else(|| "创建项目 .agent 目录失败".to_string())?;
let append_lock = project_append_lock_for(&path)?;
let _append_guard = append_lock.lock_process("Agent 本地索引")?;
verify_agent_db_directory_current(&directory)?;
let mut storage = open_agent_db_storage(directory, true, true)?
.ok_or_else(|| "创建 Agent 本地索引失败".to_string())?;
verify_agent_db_storage_current(&storage)?;
repair_truncated_jsonl_tail_unlocked(&mut storage.file, &storage.path, "Agent 本地索引")?;
verify_agent_db_storage_current(&storage)?;
let length = storage
.file
.metadata()
.map_err(|error| format!("读取 Agent 本地索引元数据失败:{error}"))?
.len();
if length > AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES {
return Err("Agent 本地索引超过扫描上限,无法追加 planning decision".to_string());
}
storage
.file
.seek(SeekFrom::Start(0))
.map_err(|error| format!("定位 Agent 本地索引失败:{error}"))?;
let mut reader = BufReader::new(&mut storage.file);
let mut count = 0usize;
while let Some(line) = read_agent_db_jsonl_line_bounded(&mut reader, &storage.path)? {
if !line.complete {
break;
}
if line.content.iter().all(|byte| byte.is_ascii_whitespace()) {
continue;
}
count = count.saturating_add(1);
if count > AGENT_DB_MAX_SCAN_RECORDS {
return Err("Agent 本地索引超过记录扫描上限".to_string());
}
let stored = serde_json::from_slice::<serde_json::Value>(&line.content)
.map_err(|error| format!("解析 Agent 本地索引失败:{error}"))?;
if stored.get("recordType").and_then(serde_json::Value::as_str) == Some(RECORD_TYPE)
&& stored.get("gddId").and_then(serde_json::Value::as_str) == Some(gdd_id)
&& stored.get("version").and_then(serde_json::Value::as_u64) == Some(version)
&& stored.get("responseId").and_then(serde_json::Value::as_str) == Some(response_id)
{
if !agent_db_record_has_exact_payload_fields(&stored, FIELDS) {
return Err("Agent DB planning decision 已有记录字段集合损坏".to_string());
}
let mut comparable = stored.clone();
if let Some(object) = comparable.as_object_mut() {
object.remove("schemaVersion");
object.remove("updatedAt");
}
if comparable == record {
return Ok(false);
}
return Err(
"PLAN_DECISION_IDENTITY_CONFLICT: planning decision 幂等键 payload 不一致"
.to_string(),
);
}
}
drop(reader);
let line = serialize_agent_db_record(record)?;
validate_agent_db_append_class_record_size(AgentDbRecordAppendClass::PlanGddDecision, &line)?;
append_agent_db_classified_line_unlocked(
&mut storage,
&line,
AgentDbRecordAppendClass::PlanGddDecision,
)?;
Ok(true)
}
pub(crate) fn append_agent_db_record_if_missing_for_action_with_before_lock<F>(
root: &Path,
record_type: &str,
@@ -3937,6 +4191,8 @@ struct AgentDbReservedTailCapacity {
action_tail_bytes: u64,
lifecycle_unlinked_tail_records: usize,
lifecycle_unlinked_tail_bytes: u64,
plan_decision_tail_records: usize,
plan_decision_tail_bytes: u64,
finalizations: BTreeMap<String, AgentDbFinalizationCapacityReservation>,
}
@@ -3959,6 +4215,13 @@ impl AgentDbReservedTailCapacity {
AgentDbRecordAppendClass::LifecycleTerminal => {
self.observe_unlinked_lifecycle(in_record_tail, tail_bytes);
}
AgentDbRecordAppendClass::PlanGddDecision => {
self.plan_decision_tail_records = self
.plan_decision_tail_records
.saturating_add(usize::from(in_record_tail));
self.plan_decision_tail_bytes =
self.plan_decision_tail_bytes.saturating_add(tail_bytes);
}
AgentDbRecordAppendClass::FinalizationCritical => {
if !self.observe_finalization_record(record, in_record_tail, tail_bytes) {
self.observe_unlinked_lifecycle(in_record_tail, tail_bytes);
@@ -4285,6 +4548,24 @@ fn ensure_agent_db_classified_capacity_unlocked(
));
}
}
AgentDbRecordAppendClass::PlanGddDecision => {
if capacity.plan_decision_tail_records
> AGENT_DB_PLAN_GDD_DECISION_RESERVE_RECORDS as usize
{
return Err(format!(
"Agent 本地索引已达到 {} 条 planning decision 尾部配额,无法继续追加:{}",
AGENT_DB_PLAN_GDD_DECISION_RESERVE_RECORDS,
path.display()
));
}
if capacity.plan_decision_tail_bytes > AGENT_DB_PLAN_GDD_DECISION_RESERVE_BYTES {
return Err(format!(
"Agent 本地索引将超过 {} 字节 planning decision 尾部配额:{}",
AGENT_DB_PLAN_GDD_DECISION_RESERVE_BYTES,
path.display()
));
}
}
AgentDbRecordAppendClass::Ordinary => unreachable!(),
}
Ok(())
@@ -1,5 +1,13 @@
# 决策记录
## 2026-08-15 M1C-1 隔离工作树收口:审批核心与专用完成门已落地,生产前置门保持后置
- **本轮落地**:在 `planning_storage.rs` 增加 `plan-gdd-approval.v1` receipt、`plan-gdd-approval-pending.v1` projection、comment/decision/receipt/pending 的 typed fingerprint 与 strict canonical 校验;审批 observation 固定校验 tool/status、版本摘要、detail 前缀和规范化 comment。`planning_approval.rs` 增加 receipt create-only、三动作幂等(`committed / replayed / already-decided`)、版本/指纹竞态防护、receipt 后 index/Markdown/audit/terminal observation/session 投影与恢复,以及只读的 plan 根专用 completion blocker`commands.rs` 暴露 `decide_game_creator_plan_gdd`
- **恢复边界**generic `plan.submit_gdd` 的 v5 standalone pending 与 v4 batch 仍是独立恢复锚点。receipt 投影只在 exact planning-submit batchv4、单 action、cursor 已到 1、completed、observation 与 receipt 逐字相等)时清理残留;pending 已缺失但 terminal observation 存在时仍校验/清理该 batch,形状不 exact 则保持 `recoveryPending`,不猜测删除。无 receipt 的 GDD 不由本包自行重建审批 pending。
- **明确后置**:技术方案第 13.0 节要求的 acceptance-gate 取证成功后才创建生产 `gdd-approval` pending;当前创建 helper 仅由定向测试调用,真实 submit/recovery caller 留给 `M1C-2a` 的验收前置门接线。审批 UI、验收图接线和构建准入仍未完成,不能把当前隔离 WIP 宣称为完整产品交付。
- **验证**:专用 target `target-m1c1-current``cargo test --all-targets planning_submit --no-fail-fast`37 passed,含 completion blocker 正向/阻塞/作用域/identity 回归)、`cargo test --all-targets planning_storage --no-fail-fast`11 passed)、`cargo check --all-targets` 通过;`npm run check:encoding`7848 files)和 `git diff --check` 通过。编译仍有仓库既有 warnings,不作为本包缺陷。
- **关联**`docs/technical/【技术方案】立项策划AgentFast GDD-2026-08-10.md` 第 13、14、23.6、23.8 节;本条只记录隔离工作树状态,合回原分支前仍需按既定合并流程复核。
## 2026-08-15 CI 五条失败归并为三个根因:修位置,不修症状
栈溢出修复后的全量跑出 5 条失败(`2051 passed / 5 failed`,**零栈溢出**,栈修复站住)。逐条定位后归并为 3 个根因,**全部先于本轮工作**:与栈修复、`M1C-0b`、以及刚合入的 master 都无关,master`9f5c84ee7`)自身全绿,`deae1e08c`(栈修复前、`M1C-0b` 前)已全挂。三者形状相同——**新增的检查被放到了链路更靠前的位置,改变的是作用域而非严格程度**,详见 pitfalls 同日条。
@@ -1,9 +1,9 @@
# 立项策划 AgentFast GDD)技术方案
- 日期: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 节),澄清轮次上限现为 3game-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)` 并最大化阻塞;不含审批写入方。当前仍未实现完整审批闭环、UI、构建准入,见第 23.6、23.8 节。后续执行计划见第 23.6 节。
- 状态: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 节),澄清轮次上限现为 3game-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)` 并最大化阻塞;不含审批写入方。**当前隔离 worktree 已完成 M1C-1 的审批核心、receipt 投影/恢复和 plan 根专用 completion blocker;生产 acceptance-gate pending caller、验收图接线、审批 UI 与构建准入仍后置,完整交付未完成**(见第 23.6、23.8 节。后续执行计划见第 23.6 节。
- 适用范围: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 的前向兼容读路径(包括既有 Provider/终态消费点的 fail-closed guard),但不构成完整可交付:审批 UI、receipt、审批等待、构建绑定和正式入口仍不可用
- 当前实现边界:本文件是后续详细设计与实现的仓库内阶段基线;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 的前向兼容读路径,当前隔离 worktree 另已补齐 M1C-1 receipt/审批核心、投影恢复和 plan 根完成门;生产 acceptance-gate pending caller、验收图接线、审批 UI、构建绑定和正式入口仍不可用
## 1. 背景与目标
@@ -1754,7 +1754,7 @@ M0 文档 PR 本身最低验证:Markdown 结构与三张 Mermaid 图可解析
| submit 专用提交/恢复分支 | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs` | **2026-08-14 按 M1B-2 收口**:在普通 dispatch 前处理 Runtime-owned submit;提交后终止策划子 run,并保留 generic v5 standalone pending + v4 batch anchors,不复用 `WaitingForUserInput`,不创建 planning pending。审批等待属于 M1C-1 |
| JSON sidecar | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/json_sidecar.rs:44-104,122-250` | 现有 writer 可覆盖;不可变文件必须新增 no-replace helper |
| 项目锁 | `apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs:85-138``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs:1467-1505` | 所有 planning mutation 在同一项目锁内重读事实 |
| completion blocker | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs:808-860,934-948``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs:1792-1832` | **2026-08-13 收窄**:现役 collaboration blocker 对**策划子 Agent**返回不适用;Supervisor run 侧不再整体豁免(见第 4.3 节)。仍需新增 plan GDD 专用完成门,检查提问/审批等待、receipt、observation、session 与 recovery 状态 |
| completion blocker | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs` | **M1C-1 隔离 worktree 已补齐**:仅对 exact `project-supervisor-plan` standard 顶层Run 生效,读取 GDD lineage、approval pending/receipt、generic submit anchors、terminal observation、decision audit、planning session 与 recovery,且只读不创建 pending;现役 collaboration blocker 对策划子 Agent 仍不适用 |
| plan 根 run 子 Agent 创建面 | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs``observe_agent_runtime_agent_delegate` / `observe_agent_runtime_agent_spawn_isolated`)、`agent/runtime_protocol/run_configuration.rs``validate_project_supervisor_plan_root_binding_at`)、`agent/prompt.rs``game_creator_project_supervisor_tool_plan_prompt` | **2026-08-14 `M1A-4` 已落地**plan 根 run 只能委派 `project-planning``agent.spawn_isolated` 一律拒,两条通道共用 typed `kind=plan-root-child-target-unsupported`plan source 下不拼 `supervisorIntro``$visualContract`。**已知残留(有意保留,见 decision-log 2026-08-14 `M1A-4` 条)**`$base``$isolatedAgentTemplates` 段仍会向 plan 根 run 列出全部专业角色名——那是 `agent.spawn_isolated` 的模板目录,因执行层硬拒而成为死文本;因此**不得**写「plan 根 run 上下文不出现其它 Agent 名」这类验收句 |
| plan retry | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/lifecycle_control.rs``resolve_game_creator_agent_runtime_retry_configuration_at`)、`runtime_driver.rs``supervisor_plan_root_identity_holds_at` | **2026-08-13 `M1A-3` 已落地 source 保源**`task.source == project-supervisor-plan` 时先走强判据,通过则保留该 source,失败 `kind=plan-root-retry-identity-unsupported`、不降级。gui/cli 仍走 `agent-background-task`。plan-session revision / `gddId` / 按 `gdd-approval` kind 禁 retry 仍属后续包(现役已拒 `waiting-*` |
| planning hydrate command | `apps/ai-game-creator-shell/src-tauri/src/commands.rs:856-875``apps/ai-game-creator-shell/src-tauri/src/main.rs:2178-2180``apps/ai-game-creator-shell/src/App.tsx:1550,1650,2957,3364,3702` | 新增单一 hydrate command/注册与前端生命周期调用;不把 runtime polling 当 GDD authority |
@@ -1921,7 +1921,7 @@ M0 完成不表示完整策划闭环已经上线。`M1A-1``M1A-4`、`M1B-1`
| 三 | `project-planning` 的 agentCatalog 登记 | **已完成**(机制冻结见第 3.1 节;**代码亦已落地**2026-08-13manifest、prompt bundle、runtime adapter、`prompt.rs` 角色合成分支及四处 needs_change 全部合入) |
| 四 | `M0A-3` 批二:拓扑与工具面部分 | **已完成**2026-08-13),拆解见下 |
| 四之余 | schema 与 golden vector 收口 | **已完成**2026-08-13),拆解见下 |
| 五 | M1 本体:策划闭环功能实现 | **`M1A-1``M1A-2``M1A-3``M1A-4``M1B-1``M1B-2``M1C-0``M1C-0b` 已落地并合入**其中 `M1B-1` 的 storage 基础、strict schema、typed 指纹、版本链、session 原子恢复、只挡写门禁及 writer/index/recovery 验证均已完成,golden vector 与 11 个定向 storage 测试通过。**`M1B-2` 工作包已通过本包门禁并合入**:已接入 `plan.submit_gdd`、四类 exact planning v3 binding/structured injection、v4 sole-submit batch、create-only 提交点、index/Markdown/session successor、策划子 run 终止及 generic v5/v4 anchor 恢复;不创建 `gdd-approval` planning pending 或审批等待。**`M1C-0b` 工作包已通过定向门禁并合入**:未知 durable status 解析为 `Unknown(raw)`,进入 completion barrier/waiting blocker,返工/Provider/终态消费点 fail closed,读-改-写保留 raw,损坏 sidecar 仍整目录锁死。`M1C-1` 及之后仍未实现。合入门见第 23.8 节 |
| 五 | M1 本体:策划闭环功能实现 | **`M1A-1``M1A-2``M1A-3``M1A-4``M1B-1``M1B-2``M1C-0``M1C-0b` 已落地并合入**当前隔离 worktree 已实现 M1C-1 审批核心、receipt 投影/恢复、terminal observation 完整性校验和 plan 根专用 completion blocker。**生产 acceptance-gate pending caller、验收图接线、审批 UI、M1C-2b 澄清中转与构建准入仍未完成**,因此 M1C-1 尚未合回、M1 整体不可交付。合入门见第 23.8 节 |
批二在 2026-08-13 拆成两半,因为其中一半在 M1 代码存在之前**做不完**:
@@ -2009,7 +2009,7 @@ M0 完成不表示完整策划闭环已经上线。`M1A-1``M1A-4`、`M1B-1`
| `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 明写「用过策划审批后回滚旧版本会让该项目静态委派面不可用」 |
| `M1C-1` | `gdd-approval` pending、审批命令、receiptreceipt 写入上述 status 与 plan 根完成门 | `M1B-2``M1C-0`(前向兼容粒度另见 `M1C-0b` | **隔离 worktree 已实现核心**:三动作幂等、版本/指纹竞态防护、receipt 后 index/Markdown/audit/terminal observation/session 投影与恢复、generic v5/v4 anchor 精确消费、terminal summary 完整性校验,以及仅作用于 exact plan 根的只读 completion blocker;已补无 pending、awaiting、receipt 后 observation 缺失、完整收口、非 plan root scope 与 pending identity 漂移回归。**生产 acceptance-gate pending caller 与验收前置取证门后置到 `M1C-2a`审批 UI / 澄清中转 / 构建准入仍未完成;本包尚未合回原分支**。连续修订 barrier 与 `UserRevisionRequested` 规则按第 23.7 节执行 |
| `M1C-2a` | Goal Contract 接线:turn 1 冻结、固定验收图、审批前置门取证 | `M1C-1``M1A-3` | turn 1 只能是一个 `agent.goal_contract``acceptanceNodes` 不接受自定义;**第 13.0 节审批前置门:取证未通过时不出现审批卡、而是产生返工委派**(取证顺序是协议时序问题,归本包而非 `M1C-1` |
| `M1C-2b` | 澄清中转接线、轮次派生、预算注入 | `M1C-2a` | 3 轮上限;continuation 重放幂等不增加轮次;答案绑定冲突被拒 |
| `M1D-1` | 前端 hydrate 与 GDD 审批卡 | `M1C-2b` | 前端只经 `hydrate_game_creator_plan_gdd_state` 读权威状态,不在页面侧合成批准事实 |