修复立项策划审批修订提交链路
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled

新增 user_revision 来源并允许审批修订更新当前 GDD 决定快照

移除 planning submit 对历史 session 决定内容的逐项门禁与自动覆盖

修复历史审批回执污染当前 approval pending 恢复投影

更新策划 Prompt、技术方案、原型说明和必要回归测试
This commit is contained in:
2026-08-27 09:22:27 +00:00
parent 41228b0cda
commit b767a6bc82
14 changed files with 324 additions and 349 deletions
@@ -19,9 +19,8 @@
## 低幻觉与 GDD 约束
- 用户描述玩法类型、机制或“像某款游戏”时,不代表授权复刻该游戏。游戏名称、世界观、角色与单位名、阵营、资源、界面术语和视觉语言必须原创;不得沿用、翻译或近似改写现有游戏的专有名称、Logo、标志性布局与受保护视觉语言,也不得把它们写进 GDD 正文、决定台账或原型验证项。用户提到的相似作品只能作为抽象品类参考,`targetUsers.referenceGames` 同样不得填入受保护名称。你的工具面窄,但内容红线不因此放宽——GDD 是整条产线的上游。
- 决定台账里,**事实归 Runtime、判断归你**。`decisions` 必须逐条包含 Runtime 已记录的全部决定(含首项 `initial-request`),id 用你提问时的 `id` 把下划线换成连字符;这些条目的 `answerSummary``answerSource``round` 由 Runtime 用用户的真实作答覆盖,你写占位值也会被替换,**不需要、也不要**为了抄准而改写或压缩用户原话。你真正决定的是 `topic``state`
- A、B 或自由填写得到的用户决定标 `confirmed`;用户选择“需要原型验证”标 `prototype_pending`,并保留同 id 的原型验证项——这两项是用户亲手选的,不得改判。只有未提问、由你按默认建议填写的字段才标 `default_pending`,其 `answerSource=default``round=0`。不要把用户选择的 B 当成默认项,也不要凭空把没问过的字段标成 `confirmed`——Runtime 会拒收任何没有对应用户作答的 `confirmed`
- 用户的自由填写没有回答你问的那道题时(他谈的是别的取舍,或者推翻了更早的决定),改这条决定的 `topic`,按他**实际说的内容**重新命名——这是你纠正错误绑定的唯一手段,Runtime 不会替你判断一句话答没答上一道题。若他对该题确实没有作出取舍,把该条降级为 `default_pending` + `answerSource=default` 并按默认建议写 `answerSummary`,再另起一条记录他实际确定下来的东西,在新条目的 `topic` 里写明与被推翻决定的关系。降级只能往这个方向;用户已作出的决定不得整条丢弃。
- 决定台账记录当前 GDD 的决定快照。澄清阶段的 A、B 或自由填写得到的用户决定标 `confirmed`,选择“需要原型验证”标 `prototype_pending`;未提问、由你按默认建议填写的字段标 `default_pending``answerSource=default``round=0`。审批阶段的用户修改意见是本轮最高优先级:由该意见新增或改写的决定使用 `answerSource=user_revision``round=0`,并按当前意见重新填写 `topic``state``answerSummary`
- 以当前 GDD 为基线,仅修改用户审批意见明确涉及的内容,以及为保持内部一致性所必需同步调整的派生内容。未被意见涉及的内容保持不变;如果意见与过去决定冲突,以最新意见为准。不要把用户未要求的其它方向自行扩展进本轮修订。提交时仍须提供完整 GDD 快照,但完整快照不代表可以任意重写未涉及内容
- `prototypeValidationItems` 是必填字段(没有就传空数组),与 `prototype_pending` 决定**一一对应**:每条 `prototype_pending` 决定必须有一个同 id 的验证项,每个验证项也必须对应一条 `prototype_pending` 决定,最多 3 项。除了用户亲选“需要原型验证”之外,你自己也可以主动标:手感、节奏、可读性、难度曲线这类你没问过、但选错就做不出首个可玩闭环的判断,标 `prototype_pending``answerSource=default``round=0`)比标 `default_pending` 诚实——那不是一个默认值,是一个没人验证过的假设。每项写清 30~90 分钟微型原型做什么、让谁试玩、观察什么信号、什么算通过。
- 不得编造具体游戏的机制、数值、销量、人群规模、团队规模或来源。写 `targetUsers` 时按已确认的类型与核心行为描述典型玩家即可。
- 只定义一个完整可玩闭环。MVP 不含多人、商城、服务器、开放世界、赛季、复杂社交、完整剧情或全量内容,除非用户明确改变范围。
@@ -157,6 +157,6 @@ pub(crate) use tool_policy_snapshot::{
agent_runtime_native_executable_tools, agent_runtime_plan_root_supervisor_tools,
agent_runtime_plan_root_supervisor_tools_for_stage,
agent_runtime_tool_policy_snapshot_for_run_at, plan_root_supervisor_stage_at,
PlanRootSupervisorStage, AGENT_RUNTIME_CANVAS_ASSET_KINDS,
AGENT_RUNTIME_PROJECT_PLANNING_ACTION_TOOLS,
plan_root_supervisor_stage_at_locked, PlanRootSupervisorStage,
AGENT_RUNTIME_CANVAS_ASSET_KINDS, AGENT_RUNTIME_PROJECT_PLANNING_ACTION_TOOLS,
};
@@ -153,8 +153,9 @@ pub(in crate::agent) fn remove_autonomous_art_director_non_canvas_validation_too
Ok(())
}
pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request_locked(
root: &Path,
project_lock: &ProjectWriteLock,
agent_id: &str,
session_id: &str,
run_id: &str,
@@ -172,6 +173,9 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
),
String,
> {
if !project_lock.guards_project_root(root)? {
return Err("构建 Agent 工具计划缺少当前项目写锁".to_string());
}
let planning_agent = agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID;
if planning_agent {
validate_project_planning_child_binding_at(root, agent_id, run_id)?;
@@ -527,7 +531,7 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
)?)
.with_tool_choice(platform_llm::LlmToolChoice::Required);
if plan_root {
let stage = plan_root_supervisor_stage_at(root, agent_id, run_id)?;
let stage = plan_root_supervisor_stage_at_locked(root, project_lock, agent_id, run_id)?;
retain_plan_root_supervisor_native_tools(&mut request.function_tools, stage)?;
// 固定单节点 schema 只对还在广告 agent.goal_contract 的阶段有意义;收窄之后
// 它已经不在目录里,此处再调只会撞上那道 fail-closed 的"缺少工具"守卫。
@@ -661,6 +665,42 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
))
}
pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
root: &Path,
agent_id: &str,
session_id: &str,
run_id: &str,
task: &str,
observations: &[AgentRuntimeToolObservation],
loop_index: usize,
mcp_catalog: &GameCreatorMcpCatalog,
) -> Result<
(
GameCreatorLlmConfig,
String,
LlmRunRequest,
String,
AgentRuntimeToolPlanRequestSnapshot,
),
String,
> {
let _lock = acquire_game_creator_agent_provider_plan_project_write_lock_with_wait(
root,
"runtime.provider_request.build.tool_plan",
)?;
build_game_creator_agent_background_tool_plan_request_locked(
root,
&_lock,
agent_id,
session_id,
run_id,
task,
observations,
loop_index,
mcp_catalog,
)
}
pub(in crate::agent) fn build_game_creator_agent_background_final_reply_request(
root: &Path,
agent_id: &str,
@@ -251,8 +251,9 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
== AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
&& agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
&& autonomous_manifest_dag_in_progress_at(root)?;
let request = build_game_creator_agent_background_tool_plan_request(
let request = build_game_creator_agent_background_tool_plan_request_locked(
root,
&_lock,
agent_id,
session_id,
run_id,
@@ -316,8 +317,9 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
== AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
&& agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
&& autonomous_manifest_dag_in_progress_at(root)?;
let request = build_game_creator_agent_background_tool_plan_request(
let request = build_game_creator_agent_background_tool_plan_request_locked(
root,
&_lock,
agent_id,
session_id,
run_id,
@@ -370,8 +372,9 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
// that lock so a session successor cannot be used to re-label an
// object assembled from an older session.
if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID {
built_request = build_game_creator_agent_background_tool_plan_request(
built_request = build_game_creator_agent_background_tool_plan_request_locked(
root,
&_lock,
agent_id,
session_id,
run_id,
@@ -230,11 +230,19 @@ mod plan_root_stage_tests {
}
/// 只按 durable 事实判定阶段,不看 Provider 说了什么。
pub(crate) fn plan_root_supervisor_stage_at(
///
/// 调用方必须已经持有当前项目写锁;需要自行取得锁的调用方使用下面的
/// `plan_root_supervisor_stage_at` 包装入口。这样 Provider 请求构建路径可以复用外层
/// 已有的项目锁,不会在阶段判定中再次获取同一把非重入锁。
pub(crate) fn plan_root_supervisor_stage_at_locked(
root: &Path,
project_lock: &ProjectWriteLock,
agent_id: &str,
run_id: &str,
) -> Result<PlanRootSupervisorStage, String> {
if !project_lock.guards_project_root(root)? {
return Err("plan Supervisor 阶段判定缺少当前项目写锁".to_string());
}
if read_game_creator_agent_runtime_goal_contract_at(root, agent_id, run_id)?.is_none() {
return Ok(PlanRootSupervisorStage::GoalContract);
}
@@ -244,12 +252,27 @@ pub(crate) fn plan_root_supervisor_stage_at(
if !delegated {
return Ok(PlanRootSupervisorStage::Delegate);
}
if plan_root_supervisor_acceptance_evidence_required_at(root, agent_id, run_id)? {
if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
&& plan_root_supervisor_acceptance_evidence_required_locked(root, project_lock, run_id)?
{
return Ok(PlanRootSupervisorStage::AwaitingAcceptanceEvidence);
}
Ok(PlanRootSupervisorStage::Delegated)
}
/// 供未持有项目写锁的调用方使用的阶段判定入口。
pub(crate) fn plan_root_supervisor_stage_at(
root: &Path,
agent_id: &str,
run_id: &str,
) -> Result<PlanRootSupervisorStage, String> {
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
"planning.supervisor-stage",
)?;
plan_root_supervisor_stage_at_locked(root, &_lock, agent_id, run_id)
}
pub(crate) fn agent_runtime_native_executable_tools() -> Vec<&'static str> {
agent_runtime_executable_tools()
.into_iter()
@@ -208,15 +208,10 @@ pub(super) fn game_creator_agent_final_reply_error_allows_fallback(error: &str)
matches!(kind.as_str(), "empty-response" | "deserialize")
}
/// `PLAN_SESSION_DECISIONS_MISMATCH` 与前两者同类:错的是本次 Provider input
/// durable 权威完好,把拒绝理由回灌给策划子 Agent 它就能改。真 CAS
/// `PLAN_SESSION_CAS_CONFLICT`)不在此列——那说明 session 已被推进或损坏,
/// 重交同一份 input 不可能成功,必须 reconcile。
/// 这些错误只描述本次 Provider input 或候选 GDD;真正的 session CAS 冲突不在
/// 此列——那说明 durable session 已被推进或损坏,必须 reconcile。
fn plan_submit_error_is_business_rejection(error: &PlanningStorageError) -> bool {
matches!(
error.code(),
"PLAN_INVALID_REQUEST" | "PLAN_SIZE_LIMIT" | "PLAN_SESSION_DECISIONS_MISMATCH"
)
matches!(error.code(), "PLAN_INVALID_REQUEST" | "PLAN_SIZE_LIMIT")
}
/// A malformed Fast GDD is useful feedback for the planning child, but it
@@ -4221,27 +4216,4 @@ mod plan_gdd_blocker_projection_tests {
"版本上限由既有 lineage 决定,重试相同 Provider submit 不会改变它"
);
}
/// 台账逐项比对失败是本次 Provider input 写错,durable 权威完好,回灌理由后
/// 策划子 Agent 能自行改稿;真 CAS 则说明 session 已被推进或损坏,重交同一份
/// input 不可能成功。两者曾共用 `PLAN_SESSION_CAS_CONFLICT`,导致前者也被判成
/// 硬阻断——实测中策划子 Agent 靠回灌连改三轮修好了形状层,紧接着撞上这一支
/// 直接 needs-reconciliation,整条链路无产物收场。
#[test]
fn session_ledger_mismatch_is_provider_feedback_but_a_real_cas_conflict_is_not() {
assert!(
plan_submit_error_is_business_rejection(&PlanningStorageError::new(
"PLAN_SESSION_DECISIONS_MISMATCH",
"submit input 未逐项匹配当前 planning session 决策摘要"
)),
"台账不匹配应回灌给 Provider 修正,受既有 5 次预算约束"
);
assert!(
!plan_submit_error_is_business_rejection(&PlanningStorageError::new(
"PLAN_SESSION_CAS_CONFLICT",
"planning session 已被其它动作推进"
)),
"真 CAS 必须走 reconciliation,不得消耗 Provider 重试额度"
);
}
}
@@ -345,30 +345,22 @@ fn latest_plan_gdd_for_root<'a>(gdds: &'a [PlanGddV1], root_run_id: &str) -> Opt
/// gate. It does not create approval pending or mutate any planning sidecar;
/// the actual pending projection remains owned by
/// `ensure_plan_gdd_approval_pending_after_acceptance_locked` after a successful
/// `agent.acceptance_update`.
pub(crate) fn plan_root_supervisor_acceptance_evidence_required_at(
/// `agent.acceptance_update`. The caller must hold the current project write
/// lock and pass that guard explicitly.
pub(crate) fn plan_root_supervisor_acceptance_evidence_required_locked(
root: &Path,
agent_id: &str,
project_lock: &ProjectWriteLock,
run_id: &str,
) -> Result<bool, String> {
if !project_lock.guards_project_root(root)? {
return Err("plan Supervisor 阶段判定缺少当前项目写锁".to_string());
}
if !crate::config::game_creator_planning_capability_enabled()? {
return Ok(false);
}
if agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID || run_id.trim().is_empty() {
if run_id.trim().is_empty() {
return Ok(false);
}
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
"planning.supervisor-stage",
)
.map_err(|error| format!("取得 plan Supervisor 阶段判定项目锁失败:{error}"))?;
plan_root_supervisor_acceptance_evidence_required_locked(root, run_id.trim())
}
fn plan_root_supervisor_acceptance_evidence_required_locked(
root: &Path,
run_id: &str,
) -> Result<bool, String> {
let gdds = read_plan_gdd_chain_locked(root).map_err(|error| error.to_string())?;
let Some(gdd) = latest_plan_gdd_for_root(&gdds, run_id) else {
return Ok(false);
@@ -1142,89 +1134,96 @@ fn project_receipt_locked(
let pending_observation = approval_observation(receipt);
let mut approval_pending_cleanup_eligible = false;
let approval_pending = match read_plan_gdd_approval_pending_locked(root) {
Ok(value) => value,
Err(error) => {
note_plan_gdd_projection_gap(
root,
receipt,
"approval-pending-read",
&error.to_string(),
);
recovery_pending = true;
None
}
};
match approval_pending {
Some(mut pending) => {
if !pending_identity_matches_gdd(&pending, receipt_gdd) {
// Approval pending is a singleton projection for the latest GDD, not a
// per-receipt projection. A historical receipt must still repair its own
// index/Markdown/audit/runtime anchors, but it must not compare the
// current pending card with its older GDD identity. After a revise/reject
// creates a newer GDD, that comparison is expected to differ.
if receipt.version == latest.version {
let approval_pending = match read_plan_gdd_approval_pending_locked(root) {
Ok(value) => value,
Err(error) => {
note_plan_gdd_projection_gap(
root,
receipt,
"approval-pending-identity",
"approval pending 与 receipt GDD identity 不一致",
"approval-pending-read",
&error.to_string(),
);
recovery_pending = true;
} else {
let expected_status = format!("observed_{}", receipt.action);
if !matches!(pending.status.as_str(), "awaiting_decision")
&& pending.status != expected_status
{
None
}
};
match approval_pending {
Some(mut pending) => {
if !pending_identity_matches_gdd(&pending, receipt_gdd) {
note_plan_gdd_projection_gap(
root,
receipt,
"approval-pending-status",
&format!(
"approval pending status={} 既不是 awaiting_decision 也不是 {expected_status}",
pending.status
),
"approval-pending-identity",
"approval pending 与 receipt GDD identity 不一致",
);
recovery_pending = true;
// Do not remove a projection whose durable state belongs
// to another decision action.
approval_pending_cleanup_eligible = false;
} else {
approval_pending_cleanup_eligible = true;
pending.status = format!("observed_{}", receipt.action);
pending.observation = Some(PlanGddApprovalObservationV1 {
tool: pending_observation.tool.clone(),
status: pending_observation.status.clone(),
summary: pending_observation.summary.clone(),
detail: pending_observation.detail.clone(),
});
match plan_gdd_approval_pending_fingerprint(&pending) {
Ok(fingerprint) => {
pending.pending_fingerprint = fingerprint;
if let Err(error) =
write_plan_gdd_approval_pending_atomic_locked(&root, &pending)
{
let expected_status = format!("observed_{}", receipt.action);
if !matches!(pending.status.as_str(), "awaiting_decision")
&& pending.status != expected_status
{
note_plan_gdd_projection_gap(
root,
receipt,
"approval-pending-status",
&format!(
"approval pending status={} 既不是 awaiting_decision 也不是 {expected_status}",
pending.status
),
);
recovery_pending = true;
// Do not remove a projection whose durable state belongs
// to another decision action.
approval_pending_cleanup_eligible = false;
} else {
approval_pending_cleanup_eligible = true;
pending.status = format!("observed_{}", receipt.action);
pending.observation = Some(PlanGddApprovalObservationV1 {
tool: pending_observation.tool.clone(),
status: pending_observation.status.clone(),
summary: pending_observation.summary.clone(),
detail: pending_observation.detail.clone(),
});
match plan_gdd_approval_pending_fingerprint(&pending) {
Ok(fingerprint) => {
pending.pending_fingerprint = fingerprint;
if let Err(error) =
write_plan_gdd_approval_pending_atomic_locked(root, &pending)
{
note_plan_gdd_projection_gap(
root,
receipt,
"approval-pending-write",
&error.to_string(),
);
recovery_pending = true;
}
}
Err(error) => {
note_plan_gdd_projection_gap(
root,
receipt,
"approval-pending-write",
"approval-pending-fingerprint",
&error.to_string(),
);
recovery_pending = true;
}
}
Err(error) => {
note_plan_gdd_projection_gap(
root,
receipt,
"approval-pending-fingerprint",
&error.to_string(),
);
recovery_pending = true;
}
}
}
}
// The approval pending projection is allowed to be absent after the
// original submit anchors have durably consumed the terminal
// observation. The generic-anchor reconciliation below decides
// whether this is a normal post-consumption state or a recovery gap.
None => {}
}
// The approval pending projection is allowed to be absent after the
// original submit anchors have durably consumed the terminal
// observation. The generic-anchor reconciliation below decides
// whether this is a normal post-consumption state or a recovery gap.
None => {}
}
let generic_submit_consumed = match project_generic_submit_observation_locked(root, receipt) {
@@ -1280,11 +1279,24 @@ fn project_receipt_locked(
// A replay may target an older receipt after a newer GDD has already been
// submitted. The receipt still repairs its own audit/observation, but it
// must not try to roll the current session or delivery lineage backwards.
let session_points_to_receipt = match read_plan_session_with_recovery_locked(root) {
Ok(session) => session
.as_ref()
.and_then(|session| session.latest_submitted_ref.as_ref())
.is_some_and(|reference| reference == &receipt_plan_ref(receipt)),
let session_projection_eligible = match read_plan_session_with_recovery_locked(root) {
Ok(session) => session.as_ref().is_some_and(|session| {
let receipt_ref_matches = session
.latest_submitted_ref
.as_ref()
.is_some_and(|reference| reference == &receipt_plan_ref(receipt));
let decision_ref_matches =
session.last_decision_ref.as_ref().is_some_and(|reference| {
reference.version == receipt.version
&& reference.response_id == receipt.response_id
&& reference.action == receipt.action
&& reference.receipt_fingerprint == receipt.receipt_fingerprint
});
decision_ref_matches
|| (receipt_ref_matches
&& session.phase == "awaiting_gdd_approval"
&& session.active_run_id.is_none())
}),
Err(error) => {
note_plan_gdd_projection_gap(root, receipt, "plan-session-read", &error.to_string());
recovery_pending = true;
@@ -1292,7 +1304,7 @@ fn project_receipt_locked(
}
};
let mut session_projection_ready = false;
if receipt.version == latest.version || session_points_to_receipt {
if session_projection_eligible {
if let Err(error) = project_plan_session_locked(root, receipt_gdd, receipt) {
note_plan_gdd_projection_gap(root, receipt, "plan-session-project", &error.to_string());
recovery_pending = true;
@@ -440,7 +440,10 @@ fn validate_decision_state(value: &str) -> Result<(), PlanningStorageError> {
}
fn validate_answer_source(value: &str) -> Result<(), PlanningStorageError> {
if matches!(value, "user_freeform" | "user_option" | "default") {
if matches!(
value,
"user_freeform" | "user_option" | "user_revision" | "default"
) {
Ok(())
} else {
Err(invalid(format!("未知 answerSource{value}")))
@@ -882,31 +885,38 @@ fn validate_decisions(
if decision.round > 3 {
return Err(invalid(format!("decisions[{index}].round 不能超过 3")));
}
// round=0 表示这条决定从未向用户提问过,因此它不能声称任何用户权威:
// answerSource 必须是 default。但它可以落在两种状态上——由 Agent 按默认
// 建议填写(default_pending),或者 Agent 判定这项会实质影响首个可玩闭环、
// 不该由它替用户拍板,需要一个 30~90 分钟微型原型来验证
// prototype_pending,并配同 id 的 prototypeValidationItems 项)。
//
// 早期实现把 round=0 钉死成 default_pending。于是用户一次把需求说全、
// 走 0 轮直出时,全部决定都是 round=0,没有任何决定可能成为
// prototype_pending;而下面的双射又要求验证项逐项对应 prototype_pending
// 决定,结果是首次 plan.submit_gdd 必被预检拒收,且这份稿子永远不可能
// 带上原型验证项。把一项未经验证的风险标成「默认,待确认」是在说谎:
// 那不是一个默认值,那是一个没人验证过的假设。
if decision.answer_source == "user_revision" && decision.round != 0 {
return Err(invalid(format!(
"decisions[{index}] 的 user_revision 必须使用 round=0"
)));
}
// round=0 不属于澄清轮:默认建议使用 default,审批修改使用
// user_revision。两者都可以标记为 prototype_pending;用户明确修改的
// 决定则可以标记 confirmed。
if decision.round == 0 && decision.id != "initial-request" {
if decision.answer_source != "default" {
return Err(invalid(format!(
"decisions[{index}] round=0 未经提问,answerSource 只能是 default"
)));
}
if !matches!(
decision.state.as_str(),
"default_pending" | "prototype_pending"
) {
return Err(invalid(format!(
"decisions[{index}] round=0 只能是 default_pending 或 prototype_pending"
)));
match decision.answer_source.as_str() {
"default"
if matches!(
decision.state.as_str(),
"default_pending" | "prototype_pending"
) => {}
"user_revision"
if matches!(decision.state.as_str(), "confirmed" | "prototype_pending") => {}
"default" => {
return Err(invalid(format!(
"decisions[{index}] round=0 的 default 只能是 default_pending 或 prototype_pending"
)));
}
"user_revision" => {
return Err(invalid(format!(
"decisions[{index}] round=0 的 user_revision 只能是 confirmed 或 prototype_pending"
)));
}
_ => {
return Err(invalid(format!(
"decisions[{index}] round=0 的 answerSource 只能是 default 或 user_revision"
)));
}
}
}
validate_text(
@@ -1100,108 +1100,6 @@ fn gdd_submit_identity_matches(gdd: &PlanGddV1, context: &PlanSubmitGddRuntimeCo
.is_none_or(|approval_request_id| gdd.approval_request_id == approval_request_id)
}
/// 决定台账的权威归属:Runtime 拥有**事实**(用户在第几轮、对着哪道题、原样说了
/// 什么),策划子 Agent 拥有**判断**(这句话是不是构成对该题的取舍、该记成什么
/// topic)。
///
/// 早期实现要求 submit input 的前缀与 `session.decisionsSummary` 六个字段逐项相等。
/// 那六个字段没有一个是子 Agent 生产的,它只能从 Supervisor 转述的委派 task 里回抄;
/// 而权威台账从不下发给它,拒绝理由也不含差异。于是「回抄」这件零信息量的动作成了
/// 唯一的提交前提,用户只要自由填写过一次,逐字复现就依赖一条没有机制保证的 LLM
/// 转述链,抄歪即在 5 次盲重试后硬失败。同一条相等约束还顺带禁掉了子 Agent 纠正
/// 错误绑定的能力——答非所问被 Runtime 投影成 confirmed 之后,改一个字都过不了。
///
/// 现在只守真正要守的那一条:**不能声称用户确认过他没确认的东西**。
fn submit_decisions_respect_session_authority(
session: &PlanSessionV1,
input: &PlanSubmitGddInputV1,
) -> bool {
// 1. 不得凭空造出用户拍板:任何 confirmed 且非默认来源的决定,都必须命中一条
// 同 id 的 confirmed session 决定。
let no_forged_confirmation = input.decisions.iter().all(|decision| {
if decision.state != "confirmed" || decision.answer_source == "default" {
return true;
}
session
.decisions_summary
.iter()
.any(|recorded| recorded.id == decision.id && recorded.state == "confirmed")
});
// 2. 不得丢弃用户已作出的决定,也不得篡改用户亲自选择的「需要原型验证」。
// confirmed 允许降级为 default_pending(子 Agent 判定该轮回答并未回答所问
// 时的唯一出口),但不能凭空消失。
let no_dropped_authority = session.decisions_summary.iter().all(|recorded| {
let Some(decision) = input
.decisions
.iter()
.find(|decision| decision.id == recorded.id)
else {
return false;
};
match recorded.state.as_str() {
"prototype_pending" => decision.state == "prototype_pending",
"confirmed" => matches!(decision.state.as_str(), "confirmed" | "default_pending"),
_ => true,
}
});
// 3. Runtime 生成的原型验证项必须都在,内容由 `apply_plan_session_authority_to_
// submit_input` 覆盖,不比较;子 Agent 可以另加自己的项,由 `validate_decisions`
// 的「逐项对应全部 prototype_pending 决定」双射约束兜底。
let no_dropped_prototype_items = session.prototype_validation_items.iter().all(|recorded| {
input
.prototype_validation_items
.iter()
.any(|item| item.id == recorded.id)
});
no_forged_confirmation && no_dropped_authority && no_dropped_prototype_items
}
/// 把 Runtime 拥有的字段直接覆盖进 submit input,而不是要求子 Agent 回抄。
///
/// 覆盖对象只有「仍然挂着用户权威」的条目:保持 confirmed 的、以及用户亲选的
/// prototype_pending。子 Agent 判定为未答而降级成 default_pending 的条目,其
/// answerSummary 描述的是它自己填的默认值,归它所有,不覆盖。`topic` 任何情况下
/// 都不覆盖——按答案真实内容重新命名决定,正是子 Agent 纠正错误绑定的手段。
///
/// 覆盖必须发生在 durable action identity 重放比对之前,且只依赖 session 里
/// 跨 submit 不变的 `decisionsSummary` / `prototypeValidationItems`
/// `build_submit_session_successor` 原样克隆这两项),这样同一个 actionId 重放
/// 时归一化结果稳定,重放比对不会因为覆盖而错判成 payload 不一致。
fn apply_plan_session_authority_to_submit_input(
session: &PlanSessionV1,
input: &mut PlanSubmitGddInputV1,
) {
for decision in &mut input.decisions {
let Some(recorded) = session
.decisions_summary
.iter()
.find(|recorded| recorded.id == decision.id)
else {
continue;
};
let carries_user_authority = match recorded.state.as_str() {
"confirmed" => decision.state == "confirmed",
"prototype_pending" => decision.state == "prototype_pending",
_ => false,
};
if !carries_user_authority {
continue;
}
decision.answer_source = recorded.answer_source.clone();
decision.round = recorded.round;
decision.answer_summary = recorded.answer_summary.clone();
}
for item in &mut input.prototype_validation_items {
if let Some(recorded) = session
.prototype_validation_items
.iter()
.find(|recorded| recorded.id == item.id)
{
*item = recorded.clone();
}
}
}
fn session_identity_matches_context(
session: &PlanSessionV1,
context: &PlanSubmitGddRuntimeContext,
@@ -1247,7 +1145,6 @@ fn build_submit_session_successor(
fn validate_current_session_cas(
session: &PlanSessionV1,
context: &PlanSubmitGddRuntimeContext,
input: &PlanSubmitGddInputV1,
) -> Result<(), PlanningStorageError> {
if !session_identity_matches_context(session, context) {
return Err(submit_error(
@@ -1283,20 +1180,6 @@ fn validate_current_session_cas(
"当前 planning session 仍有未决 GDD",
));
}
// 这一支和上面三条 CAS 判据性质不同,因此不共用 `PLAN_SESSION_CAS_CONFLICT`。
// 真 CASrevision 溢出、session 已被其它动作推进、Runtime source
// revision/fingerprint 无效)说明 durable 权威变了或坏了,重交同一份 input 也
// 没用,只能 reconcile;而台账逐项比对失败时权威完好,错的是本次 Provider
// input——策划子 Agent 把 session 决策摘要抄漏、抄错或多追加了一条非默认决定。
// 这正是第 12 节划归「本次 Provider input」的那一类,应该走 rejected
// observation 回灌让它改,受既有 5 次预算约束,而不是硬阻断等人。
// 不变量本身一个字没放松:不匹配照样拒,只是改了拒绝的后果。
if !submit_decisions_respect_session_authority(session, input) {
return Err(submit_error(
"PLAN_SESSION_DECISIONS_MISMATCH",
"submit input 的决定台账越过了 planning session 的用户权威",
));
}
if session.latest_delegation_id != context.delegation_id {
return Err(submit_error(
"PLAN_SOURCE_PROFILE_MISMATCH",
@@ -1798,23 +1681,6 @@ pub(crate) fn execute_plan_submit_gdd(
let session_read = read_plan_session_with_recovery_locked(root);
let current_session = session_read.as_ref().ok().and_then(Option::as_ref);
// Runtime 拥有的决定字段在这里一次性覆盖进 input,之后的重放比对、CAS 与 GDD
// 构建全部使用归一化后的值。放在重放分支之前是必需的:`submit_payload_matches_gdd`
// 拿 input 和已落库 GDD 反推出的 input 比对,只有两侧都归一化过才等价。归一化
// 只读 `decisionsSummary` / `prototypeValidationItems`,二者跨 submit successor
// 原样保留,所以同一 actionId 重放的结果稳定。session 读不出来时保持原样,把
// session 错误留给下面既有的分支处置。
let normalized_input;
let input = match current_session {
Some(session) => {
let mut owned = input.clone();
apply_plan_session_authority_to_submit_input(session, &mut owned);
normalized_input = owned;
&normalized_input
}
None => input,
};
// First resolve the durable action identity. This branch intentionally
// runs before pending/version checks: replay must be idempotent even when a
// previous attempt already advanced the session or projections.
@@ -1898,7 +1764,7 @@ pub(crate) fn execute_plan_submit_gdd(
));
};
validate_plan_session(current_session)?;
validate_current_session_cas(current_session, context, input)?;
validate_current_session_cas(current_session, context)?;
let version = chain
.last()
.map(|latest| latest.version.saturating_add(1))
@@ -2166,9 +2032,8 @@ mod tests {
submit_fixture_from(valid_input())
}
/// 与 `submit_fixture` 同构,但由调用方提供 inputdurable session 的
/// `decisionsSummary` / `prototypeValidationItems` 直接镜像它,于是可以构造出
/// 「用户已在第 N 轮拍板」「用户亲选了需要原型验证」这类前置台账。
/// 与 `submit_fixture` 同构,但由调用方提供 input,并用它初始化 session 的
/// 当前决定快照,便于构造澄清后或审批修订后的提交场景。
fn submit_fixture_from(
input: PlanSubmitGddInputV1,
) -> (PathBuf, PlanSubmitGddRuntimeContext, PlanSubmitGddInputV1) {
@@ -3438,38 +3303,36 @@ mod tests {
}
#[test]
fn submit_rejects_an_extra_non_default_decision_not_present_in_session() {
fn submit_allows_user_revision_decisions_outside_the_previous_session_snapshot() {
let (root, context, mut input) = submit_fixture();
input.decisions.push(PlanSubmitDecision {
id: "invented-confirmation".to_string(),
topic: "未提问决定".to_string(),
topic: "审批新增决定".to_string(),
state: "confirmed".to_string(),
answer_source: "user_option".to_string(),
round: 1,
answer_summary: "伪造为用户已确认".to_string(),
answer_source: "user_revision".to_string(),
round: 0,
answer_summary: "用户在审批意见中明确提出".to_string(),
});
let error = execute_plan_submit_gdd(&root, &context, &input)
.expect_err("a non-default decision outside the session prefix must fail");
// 伪造用户确认照样被拒;只是错误码从 CAS 换成了可回灌的输入类,
// 让策划子 Agent 能按理由改稿而不是把整个 Agent 阻断到人工核对。
assert_eq!(error.code(), "PLAN_SESSION_DECISIONS_MISMATCH");
assert!(!root.join(".agent/planning/gdd.v1.json").exists());
execute_plan_submit_gdd(&root, &context, &input)
.expect("a user revision may add a decision to the new snapshot");
let chain = read_plan_gdd_chain(&root).expect("read chain");
assert_eq!(
chain[0].decisions.last().unwrap().answer_source,
"user_revision"
);
cleanup_fixture(root);
}
/// 用户答案原文归 Runtime 所有:子 Agent 抄歪了直接被覆盖回去,而不是把整条
/// 提交拒掉。真 CAS(durable 权威已变)仍然是另一回事,必须区分开。
/// 新版本的决定快照由本次提交负责,旧 session 不再覆盖其内容。
#[test]
fn a_rewritten_answer_summary_is_overwritten_while_a_stale_session_is_still_a_cas_conflict() {
// 抄错既有决定的正文(权威没变,错的是 input):落库的是权威原文。
fn a_rewritten_answer_summary_is_preserved_while_a_stale_session_is_still_a_cas_conflict() {
let (root, context, mut input) = submit_fixture();
let authoritative = input.decisions[0].answer_summary.clone();
input.decisions[0].answer_summary.push_str("(被改写)");
execute_plan_submit_gdd(&root, &context, &input)
.expect("a rewritten answer summary is overwritten, not rejected");
.expect("the current submit snapshot owns its decision text");
let chain = read_plan_gdd_chain(&root).expect("read submitted chain");
assert_eq!(chain[0].decisions[0].answer_summary, authoritative);
assert!(chain[0].decisions[0].answer_summary.ends_with("(被改写)"));
cleanup_fixture(root);
// 同一份合法 input,只把 session revision 弄陈旧(权威已被推进)。
@@ -3527,7 +3390,7 @@ mod tests {
chain[0].decisions[1].topic,
"重玩动力(用户实际回答的是这个)"
);
// 但答案原文仍然是 Runtime 的权威值
// 当前提交快照保留 Provider 生成的答案正文
assert_eq!(
chain[0].decisions[1].answer_summary,
"不要那两个,我要玩家只能移动光源给守卫开路"
@@ -3535,17 +3398,16 @@ mod tests {
cleanup_fixture(root);
}
/// 降级(confirmed → default_pending)是允许的安全方向;整条丢掉不行——那会让
/// 用户已经作出的决定从 GDD 里凭空消失。
/// 修订可以删除、重写或重新定义旧决定;Runtime 不把旧 session 快照当内容门禁。
#[test]
fn a_confirmed_decision_may_be_downgraded_but_never_dropped() {
fn a_revision_may_downgrade_or_drop_an_obsolete_decision() {
let (root, context, mut input) = submit_fixture_from(clarified_input());
input.decisions[1].state = "default_pending".to_string();
input.decisions[1].answer_source = "default".to_string();
input.decisions[1].answer_summary = "按默认建议填写,等待用户确认".to_string();
execute_plan_submit_gdd(&root, &context, &input).expect("downgrade is the safe direction");
execute_plan_submit_gdd(&root, &context, &input).expect("revision may change a decision");
let chain = read_plan_gdd_chain(&root).expect("read submitted chain");
// 降级之后这条不再声称用户拍过板,正文归子 Agent 所有,不被覆盖
// 新快照按 Provider 提交内容保存
assert_eq!(chain[0].decisions[1].state, "default_pending");
assert_eq!(
chain[0].decisions[1].answer_summary,
@@ -3555,21 +3417,19 @@ mod tests {
let (root, context, mut input) = submit_fixture_from(clarified_input());
input.decisions.remove(1);
let error = execute_plan_submit_gdd(&root, &context, &input)
.expect_err("dropping a user decision must fail");
assert_eq!(error.code(), "PLAN_SESSION_DECISIONS_MISMATCH");
execute_plan_submit_gdd(&root, &context, &input)
.expect("revision may remove an obsolete decision");
cleanup_fixture(root);
}
/// 用户亲手选的「需要原型验证」不是子 Agent 可以改判的东西
/// 修订可以重新定义原型验证范围,但结构约束仍然有效
#[test]
fn a_user_picked_prototype_validation_cannot_be_rewritten_by_the_planning_child() {
let (root, context, mut input) = submit_fixture_from(clarified_input());
input.decisions[2].state = "confirmed".to_string();
input.prototype_validation_items.clear();
let error = execute_plan_submit_gdd(&root, &context, &input)
.expect_err("a user-picked prototype validation must survive");
assert_eq!(error.code(), "PLAN_SESSION_DECISIONS_MISMATCH");
execute_plan_submit_gdd(&root, &context, &input)
.expect("revision may remove an obsolete prototype item");
cleanup_fixture(root);
}
@@ -3639,32 +3499,32 @@ mod tests {
cleanup_fixture(root);
}
/// round=0 放开的只是状态,不是权威:从未提问过的决定仍然不许声称用户拍过板,
/// 也不许挂上任何 user_* 来源。
/// round=0 区分默认建议与审批修订来源;澄清来源仍不能伪装成 round=0。
#[test]
fn a_round_zero_decision_still_cannot_claim_any_user_authority() {
fn round_zero_accepts_user_revision_but_rejects_clarification_sources() {
let mut confirmed = valid_input();
confirmed.decisions.push(PlanSubmitDecision {
id: "invented".to_string(),
topic: "没问过却声称已确认".to_string(),
topic: "审批修改的决定".to_string(),
state: "confirmed".to_string(),
answer_source: "default".to_string(),
answer_source: "user_revision".to_string(),
round: 0,
answer_summary: "伪造".to_string(),
answer_summary: "用户在审批意见中明确修改".to_string(),
});
validate_plan_submit_gdd_input(&confirmed).expect_err("round=0 may not be confirmed");
validate_plan_submit_gdd_input(&confirmed)
.expect("user_revision may be confirmed at round=0");
let mut sourced = valid_input();
sourced.decisions.push(PlanSubmitDecision {
id: "invented".to_string(),
topic: "没问过却挂上用户来源".to_string(),
topic: "澄清来源不能伪装为 round=0".to_string(),
state: "prototype_pending".to_string(),
answer_source: "user_option".to_string(),
round: 0,
answer_summary: "伪造".to_string(),
});
validate_plan_submit_gdd_input(&sourced)
.expect_err("round=0 may not carry a user answer source");
.expect_err("round=0 may not carry a clarification answer source");
}
#[test]
@@ -4292,7 +4152,7 @@ mod tests {
let mut next_context = context.clone();
next_context.source_session_revision = continuation.session_revision;
next_context.source_session_fingerprint = continuation.session_fingerprint.clone();
validate_current_session_cas(&continuation, &next_context, &input)
validate_current_session_cas(&continuation, &next_context)
.expect("reject 之后的 continuation 必须能提交同一 lineage 的下一版本");
// 提交闸放行还不够:投影守卫必须认同一条 continuation。这条 session 必然带着
@@ -4304,9 +4164,25 @@ mod tests {
next_context.action_fingerprint = "4".repeat(64);
next_context.approval_request_id =
Some("gdd-approval-00000000-0000-4000-8000-000000000041".to_string());
let resubmit =
execute_plan_submit_gdd(&root, &next_context, &input).expect("continuation 提交 v2");
let mut revised_input = input.clone();
revised_input.game.title = "审批修订后的标题".to_string();
revised_input.decisions.push(PlanSubmitDecision {
id: "approval-scope".to_string(),
topic: "审批修改范围".to_string(),
state: "confirmed".to_string(),
answer_source: "user_revision".to_string(),
round: 0,
answer_summary: "用户要求采用新的首版范围".to_string(),
});
let resubmit = execute_plan_submit_gdd(&root, &next_context, &revised_input)
.expect("continuation 提交 v2");
assert_eq!(resubmit.gdd_ref.version, 2);
let chain = read_plan_gdd_chain(&root).expect("read revised GDD chain");
assert_eq!(chain[1].game.title, "审批修订后的标题");
assert_eq!(
chain[1].decisions.last().unwrap().answer_source,
"user_revision"
);
assert!(
!resubmit.recovery_pending,
"提交闸放行的 continuation,投影守卫也必须放行"
@@ -4734,6 +4610,22 @@ mod tests {
.expect("classify plan root stage"),
PlanRootSupervisorStage::AwaitingAcceptanceEvidence
);
let project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
&root,
"test.plan-root-stage-locked",
)
.expect("acquire plan root stage lock");
assert_eq!(
plan_root_supervisor_stage_at_locked(
&root,
&project_lock,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&root_runtime.run_id,
)
.expect("classify locked plan root stage"),
PlanRootSupervisorStage::AwaitingAcceptanceEvidence
);
drop(project_lock);
assert_eq!(
agent_runtime_plan_root_supervisor_tools_for_stage(
PlanRootSupervisorStage::AwaitingAcceptanceEvidence
@@ -1153,8 +1153,8 @@ fn plan_string_array_schema(min_items: usize, max_items: usize, item_max_length:
/// Strict provider-facing shape for `plan-submit-gdd-input.v1`.
///
/// Runtime-injected identity, platform facts, version and fingerprint fields
/// deliberately do not appear here. The durable handler performs the
/// semantic/session equality checks after parsing this wire shape.
/// deliberately do not appear here. The durable handler performs the
/// structural, identity and CAS checks after parsing this wire shape.
fn plan_submit_gdd_input_schema() -> Value {
let decision_state = json!({
"type": "string",
@@ -1162,7 +1162,7 @@ fn plan_submit_gdd_input_schema() -> Value {
});
let answer_source = json!({
"type": "string",
"enum": ["user_freeform", "user_option", "default"]
"enum": ["user_freeform", "user_option", "user_revision", "default"]
});
let pillar = json!({
"type": "object",
+5 -1
View File
@@ -256,7 +256,11 @@ export interface PlanGddStateViewV1 {
id: string;
topic: string;
state: 'confirmed' | 'default_pending' | 'prototype_pending';
answerSource: 'user_option' | 'user_freeform' | 'default';
answerSource:
| 'user_option'
| 'user_freeform'
| 'user_revision'
| 'default';
round: number;
answerSummary: string;
basis: null;
@@ -6,6 +6,8 @@
- **决策**plan 根阶段增加轻量的 `AwaitingAcceptanceEvidence` 状态。当前根最新 GDD 无 approval receipt/pending、session `latestSubmittedRef` 精确指向该提交、delivery 已由根认领且 Acceptance Graph 返回 `NeedsEvidence` 时,只广告 `file.read``agent.acceptance_update``agent.run_status`;只有用户真正对最新审批卡选择修改/退回后,才恢复 `agent.delegate`
- **边界**:不放宽 Provider usage 门禁,不重构 delegation/repair lineage,不自动生成证据或审批 pending;审批 pending 仍只由既有 acceptance gate 在 `agent.acceptance_update` 成功后创建。
- **验证**:新增一条阶段工具面回归,并通过 15 条 M1C-2a acceptance gate 定向测试、plan root 原生工具目录测试、`cargo check --all-targets`、格式与 diff 检查。
- **锁边界修正(2026-08-27**:阶段判定拆为 `plan_root_supervisor_stage_at_locked` 与负责取得一次项目锁的外层入口;Provider tool-plan builder 已持有项目锁时直接复用 locked 入口。Acceptance Evidence 判据和阶段工具面不变,禁止在持锁调用链中再次获取 `.agent/project.lock`
- **回归验证**planning submit 定向测试 68 passed、Provider request builder 定向测试 17 passed、Tauri `cargo check``cargo fmt --check` 通过。
## 2026-08-24 AGC Direct 媒体能力只通过客户端语义工具开放
@@ -14735,6 +14737,19 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 安全:DirectProject 使用真实 `game/` writable root、`approvalPolicy=never`,原生命令网络保持关闭,联网资料继续走受控 `agc_web_search`Codex 子 Agent、Apps、插件、hooks、图片生成、Goals、Workspace Dependencies、Tool Suggestion 与未审计浏览器/电脑控制继续关闭。配置了 AGC LLM Key 或可解析的 `OPENAI_API_KEY` 登录态时,真实 provider 凭据只留在 AGC 本地代理;前者仍走已配置上游,后者只走 OpenAI 官方 API,Codex 仅获得连接级随机代理令牌。无法安全代理的 OAuth `auth.json` 继续关闭原生 shell/unified exec。app-server 使用隔离 `CODEX_HOME`shell 用 `shell_environment_policy` glob 排除 provider key、proxy、loopback bridge 和受控开关。
- 上下文:Direct 系统提示词只保留身份、cwd、边界和 Skill 索引;不再预注入项目源码快照、项目提示词或 Skill 正文。浏览器工具回传结构化事实,不强制固定三次整改循环;Codex 自行解释证据并决定是否继续。sandbox writableRoots 不提供 deny-read`.agent`/`../assets` 的不可读约束需靠行为合同和真实 smoke 验证。
## 2026-08-27 GDD 修改后历史 receipt 不得污染当前审批恢复
- 现象:GDD“修改”已成功生成下一版本且当前 pending 身份正确,但 hydrate 持续返回 `recoveryPending=true`,审批卡显示“审批状态正在恢复”。
- 原因:恢复扫描会重放全部历史 approval receipt;旧版本 receipt 仍拿当前单例 approval pending 做 identity 比对。修改后当前 pending 已属于新版本,旧 receipt 的 identity 不同是正常状态,却被误记为投影缺口。
- 决策:receipt 的 index、Markdown、audit、submit observation、session 等投影继续允许全量恢复;approval pending 只由 lineage 最新 GDD 的 receipt 读取、更新和清理。历史 receipt 不得检查或改写当前 pending,也不得因此提升 `recoveryPending`
- 验证:沿用现有审批恢复与 planning submit 定向测试;未新增独立测试,避免为非代表性 fixture 引入额外状态构造。
## 2026-08-27 审批修订以最新用户意见更新 GDD 决定快照
- `decisions` 表示当前 GDD 版本的决定快照,不再作为新提交必须逐项复制的 session 历史前缀。审批修订可以修改、推翻、删除或新增决定;Runtime 只校验结构、身份、CAS、版本和原型验证项双射,不做自然语言修改范围门禁。
- 新增 `answerSource=user_revision`,用于标记来自审批修改意见的当前决定,按 `round=0` 记录;`default` 仍只表示未提问的默认建议,澄清来源仍使用 `user_option` / `user_freeform`
- planning Prompt 约束为:以当前 GDD 为基线,仅修改用户意见明确涉及的内容及保持内部一致性所必需的派生内容,未涉及内容保持不变;意见与旧决定冲突时以最新意见为准。
## 2026-08-24 AGC UI 原型桥接与自主 UI workflow
- 决策:`ui-prototype` 图片与 `UI` JSON 编辑资源保持两种正式类型。Agent 通过受控 `ui.workflow.run``prepare -> recognize -> status -> finalize` 创建页面资源、关联源图、持久化 UI State 和 manifest 阶段;`recognize` 直接复用 UI Editor 的 provider-backed 结构识别、多树合并与组件绑定命令,按 `reference-ready -> structure-ready -> merge-ready -> binding-ready` 逐阶段写入并推进项目 revision。页面可显式关联已登记图片/图标和字体,图片/图标按 5 项一批绑定,字体安全元数据进入绑定上下文且未知引用失败关闭。Runtime 回执携带 `revisionAdvanceCount`;Provider 未配置、请求失败、工具调用缺失、结果不匹配、未产出可渲染组件或仍有待审节点时保留最近真实阶段,禁止用 deterministic seed 冒充语义处理完成。
@@ -1,5 +1,13 @@
# 踩坑与排障记录
## 2026-08-27 阶段判定不能在持锁的 Provider builder 中再次获取项目锁
- **现象**:GDD 修订取证阶段新增后,重新启动策划时前两步表面成功,但父 Supervisor 在收到 `project-planning` 回执、生成下一轮工具计划时失败:`项目正在被其他写操作占用:$PROJECT_ROOT\\.agent\\project.lock`
- **原因**`provider_tool_plan` 在构建请求前已持有 `.agent/project.lock``plan_root_supervisor_stage_at` 又调用会自行取锁的 Acceptance Evidence 包装入口。同一进程的文件锁不可重入,持锁调用被误判为外部竞争,等待约 10 秒后失败。问题与 Provider、代理端口或 GDD 内容无关。
- **处理**:所有需要一致快照的状态读取保留在项目锁内;阶段判定提供明确的 `*_locked` 内部入口,外层入口仅供未持锁调用方取得一次锁。Provider builder 显式接收并校验当前锁后调用 locked 阶段判定,不引入可重入锁,也不移除 Acceptance Evidence 门禁。
- **排查顺序**:先看失败 Run 的事件顺序是否为 `delegate receipt ready → 生成工具计划 → 阶段判定项目锁失败`,再检查调用方是否已持有 Provider plan project lock;不要因为错误文案包含“其他写操作”就先扩大锁等待或放宽 Provider usage。
- **验证**`cargo check``cargo fmt --check`、planning submit 68 passed、Provider request builder 17 passed;阶段测试同时覆盖未持锁包装入口和持锁 locked 入口。
## 2026-08-26 GDD 新版本提交后不能沿用“已有委派”工具面
- **现象**`plan_root_supervisor_stage_at` 只按是否存在 delivery 判定 `Delegated`。用户修订产生的新 GDD 仍未完成当前根 Run 的 `file.read → agent.acceptance_update` 取证时,模型会看到 `agent.delegate`,可能重复派发同一条策划链。
@@ -4968,3 +4976,9 @@
- 严格图集崩溃补充:规范图和背景图的两文件 rollback 不覆盖严格图集事务已经整体修改的 `.agent/manifest.json`、私有回执、公开清单、主图集、四切片和切片清单。必须在严格调用前持久化 pending 及九项旧合同身份;重启恢复先对账底层严格事务,完整新合同直接收口完成,完整旧合同才补偿前两阶段,混合或漂移状态失败关闭。不要在严格提交成功后局部恢复前两张图。
- 部分旧包补充:rollback 的规范图/背景图必须保存旧字节与旧 manifest entry,不能把这两项缺失隐式当成空内容;显式 `regenerate` 因此只在这两项可信可回滚时开放。历史主图集、私有回执、公开清单或 canonical 切片可以缺失,但八个严格路径与受管顶层 asset identity 必须逐项冻结其真实 `Present/Some``Missing/None` 状态,补偿也必须恢复相同存在性。不要因为旧美术包缺切片而阻断重生成,也不要把本轮新建的严格文件误记成旧文件。
- 对话扫描与 claim 补充:历史中出现 `User A / User B / Assistant B` 时,B 已回答不代表 A 已回答,扫描必须继续寻找 A。成功 Direct 回复在 Rust 返回前已经落盘,前端冗余 append 失败不能据此重跑;普通错误回复的显式落盘失败时,恢复 claim 要保持到 React fallback writer 的同一 messageId append 明确收敛。writer 成功或明确失败后才释放;失败路径要停止该消息的自动迟到重试,再由显式 `/history` 复用原 stable turn。终态后及时删除 claim,避免 Set 无界增长。
## GDD 历史审批回执误触发当前恢复提示(2026-08-27)
- 现象:修改 GDD 后新版本标题和内容已正确落盘,但审批卡一直显示“审批状态正在恢复”。
- 原因:`approval pending` 是当前 lineage 最新 GDD 的单例投影;恢复扫描却让每个历史 receipt 都拿它做 identity 比对。旧 receipt 与新 pending 不同并不表示损坏。
- 处理:历史 receipt 只修复自身投影;只有最新 GDD 的 receipt 才能校验、更新或清理当前 approval pending。不要在前端隐藏 `recoveryPending`,也不要取消最新版本的 identity fail-closed 检查。
@@ -171,7 +171,7 @@ D9/D10 描述的「manifest ready-task 调度器在 Supervisor 下游启动策
| hydrate read-model schema | `plan-gdd-state-view.v1` |
| GDD 状态 | `draft \| ready_for_approval \| revision_requested \| approved \| rejected \| superseded` |
| 单项决定状态 | `confirmed \| default_pending \| prototype_pending` |
| 回答来源 | `user_option \| user_freeform \| default` |
| 回答来源 | `user_option \| user_freeform \| user_revision \| default` |
| 审计 recordType | `agent.runtime.plan.gdd_decided` |
| planning typed 指纹文本 | `sha256-serde-json-v2:<64 位小写十六进制>` |
| 现役 action/profile binding digest | `<64 位小写十六进制>`,无前缀 |
@@ -400,7 +400,7 @@ Runtime 注入并强校验以下精确结构:
- **轮次计数与上限**:本轮是第几轮由委派链上的 `clarification_round` 派生值决定(沿 `repair_of_delegation_id` 上溯推断,见第 23.5 节),上限 3;不再由 session 自行累加 `roundsUsed`。session 仍是 decisions 数组与 GDD 草稿内容的权威,但**不再是轮次状态机的权威**。
> **随之而来的合同影响 —— 2026-08-13 已全部收口。** 第 3 节注册表的 `plan-decision-checkpoint.v1` 与 request kind、第 8.6 节 `plan-session.v1``activeQuestion` / `roundsUsed` / `supersededCheckpointHandoffs`、第 9 节的 checkpoint domain 与 `supersededCheckpointProviderRequestIds`、第 12 节的 checkpoint stale 状态机、第 14 节与 activeQuestion 相关的恢复行,均已随本节重写一并删除或改写;第 9.1 节 golden vector 已按新 identity 重新生成(3857 bytes`a59856de7e…`)。
- 用户明确输入优先于 Agent 默认;默认建议必须标为 `default_pending`,手感、节奏、镜头、可读性或重玩差异等需要验证的结论标为 `prototype_pending`
- 用户明确输入优先于 Agent 默认;默认建议必须标为 `default_pending`,手感、节奏、镜头、可读性或重玩差异等需要验证的结论标为 `prototype_pending`审批阶段的用户修改意见使用 `answerSource=user_revision``round=0`
- session revision 1 由 Runtime 先写入固定 `initial-request` 决定:topic=`初始需求`、state=`confirmed`、answerSource=`user_freeform`、round=0、answerSummary 精确等于规范化后的 1400 scalar 初始用户需求。Provider 不能改写或省略这条来源记录;超过上限的初始输入先要求用户收束,不能截断。
### 5.2 决策卡
@@ -661,7 +661,7 @@ Provider 只能提交设计内容,不能提交或覆盖任何 Runtime 身份
该 input 及所有嵌套类型都使用 `deny_unknown_fields``game.platformFacts`、任意 `basis``projectId/gddId/version/submissionId/approvalRequestId`、action/run/session identity、时间与任何 fingerprint 一旦出现在 Provider input 中即返回 `PLAN_INVALID_REQUEST`。Runtime 在发出本轮 Provider request 前把当前 `sessionRevision/sessionFingerprint` 绑定进内部执行上下文,在项目锁内验证该 CAS 后,才把 project、GDD、版本、durable action、source/profile、session/run、时间、固定 `platformFacts`、全部 `basis:null` 与 fingerprint 注入 `plan-gdd.v1`。字段数量和文本限制按第 8.3 节对应 durable 字段执行。
input 中必须逐项包含并精确等于 source session 的全部 `decisionsSummary``prototypeValidationItems`,不得改变决定的 id/topic/state/answerSource/round/answerSummary,也不得改变原型项正文或顺序;每个已提问决定因此具有可验证的 1~3 轮来源。额外 decision 只允许是未提问默认:`default_pending + default + round=0`,且不能为它伪造 prototype item。唯一允许`confirmed + user_freeform + round=0` 是 Runtime 创建的固定 `initial-request`,其 answerSummary 精确等于初始用户需求。直接出稿因此可以合法使用 `roundsUsed=0`,但仍至少提交该初始 decision
input 是当前 GDD 的完整快照,不要求与 source session 的 `decisionsSummary``prototypeValidationItems` 逐项相等。审批修订可用 `user_revision + round=0` 修改、删除或新增决定;未涉及内容由 Agent 以当前 GDD 为基线保持不变。Runtime 仍校验决定结构、原型项双射、`initial-request` 首项、身份和 CAS。唯一固定`confirmed + user_freeform + round=0` 是 Runtime 创建的 `initial-request`
### 8.3 `plan-gdd.v1`
@@ -1150,15 +1150,7 @@ GDD handler 只能从已验证 batch binding 复制 `sourceSessionRevision/sourc
4. Provider transient failure/物理中断但 session、context 和 request slot 未变时,才沿用同一 base ID 的 attempt 派生规则。已知 retryable transport/upstream failure 先把旧 attempt durable 闭合为 `failed`Runner/进程恢复只有在 boot/owner/lease 证据证明旧物理请求不再存活且无 handoff/batch 时,才闭合为 `interrupted`。旧终态写入、同步并回读成功后,才能创建 attempt N+1 的新 `started`;不能原地复用同一 providerRequestId,也不能让两个 started attempt 并存。无法证明旧请求已终止时进入 recovery required,不自动重发。每个 attempt 始终有独立 `started → completed|failed|interrupted` lifecycle。
5. 除第 12 项明确允许的同 binding `started + ready batch` 崩溃组合,以及上文 delivery 问题落盘/答案绑定的已消费证明外,binding 缺失/损坏、lifecycle 与 batch 不一致、同 revision 下 requestContextFingerprint 漂移、session 不是合法 successor,或 batch 已进入执行/等待状态时返回 `PLAN_NEEDS_RECONCILIATION`。此路径不自动删除、不补默认 binding、不重绑、不重试。
严格 submit input 被 Runtime 以 `PLAN_INVALID_REQUEST``PLAN_SESSION_DECISIONS_MISMATCH`2026-08-21 补,见下)或由该输入导出的候选 GDD `PLAN_SIZE_LIMIT` 拒绝时,当前策划子 run 最多产生 **5 次** `plan.submit_gdd / rejected` observation:前 4 次关闭原 sole-action batch 后可在同一 run 续跑,让 Provider 根据最后一条 observation 修正;第 5 次仍须先完整落盘 rejected observation,再把该 run 终态失败,**不得**请求第 6 次 Provider tool-plan。该分类只针对本次 Provider input / 候选 GDD;读取既有不可变 GDD 或 receipt 时出现同名大小上限、既有 lineage 已达版本上限,或任何其它 durable authority 异常,一律是 `PLAN_NEEDS_RECONCILIATION`,不得消耗 Provider 重试额度。计数是 Runtime state 的 durable、每个 child run 独立的字段,进程重启不能清零;只有新建的策划 child run 才从 0 开始。它不依赖前端、Prompt 文字或 Provider 自报,且普通工具 observation 不计入。
**2026-08-21)台账逐项比对失败从 `PLAN_SESSION_CAS_CONFLICT` 拆出为 `PLAN_SESSION_DECISIONS_MISMATCH`,并纳入上述可重试分类。** 第 8.2 节「input 必须逐项包含并精确等于 source session 的 `decisionsSummary``prototypeValidationItems`」这条校验(实现为 `planning_submit.rs``session_decisions_match_input`)原先与三条真 CAS 判据(`sessionRevision` 溢出、session 已被其它动作推进、Runtime source revision/fingerprint 无效)共用一个错误码,因此被 `plan_submit_error_is_business_rejection` 漏掉,一次不匹配即 `needs-reconciliation` 硬阻断整个策划子 Agent。
两者性质本就不同,按本节自己的判据即可区分:真 CAS 说明 **durable 权威**已变或已坏,重交同一份 input 不可能成功;台账不匹配时权威完好,错的是**本次 Provider input**——策划子 Agent 把决策摘要抄漏、抄错,或多追加了一条非 `default_pending` 决定。后者正是本节划归「本次 Provider input / 候选 GDD」的那一类。
**不变量未放松**:不匹配照样拒绝、照样不产生任何事实,只是拒绝的后果从「叫人核对」变成「回灌 rejected observation 让 Provider 改稿」,仍受同一个 5 次 durable 预算约束,第 5 次照常终态失败。伪造用户确认(追加 `confirmed + user_option`)等第 8.2 节禁止的写法一条都没有变得可行。
**触发这次拆分的实测**:策划子 Agent 连续三次 submit 撞形状层(`PLAN_INVALID_REQUEST`),每次都按回灌的理由改对一部分——机制运转正常;第四次形状终于合法,随即撞上台账比对这一支,直接 `needs-reconciliation`,整条链路零产物收场。即**越接近提交成功越容易撞上不给重试的门**,这与「5 次预算让 Provider 自行收敛」的设计意图直接冲突。
严格 submit input 被 Runtime 以 `PLAN_INVALID_REQUEST` 或由该输入导出的候选 GDD `PLAN_SIZE_LIMIT` 拒绝时,当前策划子 run 最多产生 **5 次** `plan.submit_gdd / rejected` observation:前 4 次关闭原 sole-action batch 后可在同一 run 续跑,让 Provider 根据最后一条 observation 修正;第 5 次仍须先完整落盘 rejected observation,再把该 run 终态失败,**不得**请求第 6 次 Provider tool-plan。该分类只针对本次 Provider input / 候选 GDD;读取既有不可变 GDD 或 receipt 时出现同名大小上限、既有 lineage 已达版本上限,或任何其它 durable authority 异常,一律是 `PLAN_NEEDS_RECONCILIATION`,不得消耗 Provider 重试额度。计数是 Runtime state 的 durable、每个 child run 独立的字段,进程重启不能清零;只有新建的策划 child run 才从 0 开始。它不依赖前端、Prompt 文字或 Provider 自报,且普通工具 observation 不计入。
第 2 项的自动前滚必须与 session successor、batch supersede/cleanup 和 replacement request 的 started 写入都在项目锁内按幂等步骤恢复;任一断点重启后只能继续相同步骤。这样合法 steer 能确定性替换旧输出,而身份污染不会被“自动恢复”掩盖。
@@ -1167,7 +1159,7 @@ GDD handler 只能从已验证 batch binding 复制 `sourceSessionRevision/sourc
main loop 不能把 submit 当成普通 action dispatch:在 durable action identity 建立后、生成普通 command ID 或进入 action executor 前,必须进入 `plan.submit_gdd` 专用分支。该分支重验 exact plan identity,执行下列提交与投影。**2026-08-14 按 M1B-2 实现边界收口)** 本包只负责校验、定版、写不可变 GDD、重建 index、渲染 `game/fast_gdd.md`、安装 session successor 并终止策划子 run**不创建 `.agent/planning/pending.json` / `gdd-approval` planning pending,不创建审批卡,也不把 Supervisor 或策划子 run 投影为审批等待**。`gdd-approval` pending 与 Supervisor 等待态属于 `M1C-1`,还要受第 13.0 节 `M1C-2a` 验收取证门约束。原 submit 在进入专用分支前已经建立的 generic `game-creator-pending-action.v5` standalone pending 与 `game-creator-provider-action-batch.v4` action batch 必须原样保留,作为后续 receipt/terminal observation 的同 action 恢复锚点;GDD create 成功不等于该 action 已 observed。
1. 解析第 8.2 节 strict input;在项目锁内重读 project identity、策划子 run 与委派根身份、Provider request 所绑定的 session CAS、canonical GDD 链及原 submit 的 generic v5 standalone pending / v4 batch anchors。不信任 Provider payload 中不存在也不允许出现的版本、时间、平台事实或身份;M1B-2 不读取或创建尚未实现的 approval receipt / planning pending。
2. 验证文本上限、轮次、决定状态和 prototype item 一一对应;`decisions` 必须先逐项等于 source session 的完整决定前缀,前缀之后只允许追加 `state=default_pending + answerSource=default + round=0` 的未提问默认决定,任何伪造为用户已确认的额外决定都按 session CAS 冲突拒绝。Runtime 注入固定 platformFacts 和所有 `basis:null`,以当前 durable actionId/裸 action fingerprint 作为 submission identity。
2. 验证文本上限、轮次、决定状态和 prototype item 一一对应;`decisions` 按本次完整 GDD 快照校验,不与旧 session 内容逐项比较。`round=0` 的非首项决定只能是 `answerSource=default`(默认建议)或 `answerSource=user_revision`(审批修改),分别对应允许的状态集合。Runtime 注入固定 platformFacts 和所有 `basis:null`,以当前 durable actionId/裸 action fingerprint 作为 submission identity。
3. M1B-2 尚无 receipt writer:只要已有任一 GDD,新的不同 submissionId 就返回 `PLAN_PENDING_GDD_EXISTS`;同 submissionId 只允许按历史 binding replay。`M1C-1` 接入有效 approve/revise/reject receipt 后,才把边界扩为“最新版本已有 receipt 才允许下一版本”。
4. 当前 M1B-2 的首次版本固定为 1;未来版本仍只能取最后一个连续有效版本加一,范围 1~128,不允许缺号或扫描任意文件补号。
5. 新提交由 Runtime 生成并冻结 `approvalRequestId/createdAtUtc`,填充全部 durable identity、source session binding 和时间,计算 GDD fingerprint,以第 10.1 节算法 create-only 发布 `gdd.v{N}.json`。同 submissionId replay 必须先找到并严格读取既有 GDD,复用其中 Runtime 生成的版本、request/time 与 identity 后再比较,不能用新时间制造假冲突。
@@ -1447,7 +1439,6 @@ type PlanGddError = {
| 'PLAN_UNSUPPORTED_KNOWLEDGE_BASIS'
| 'PLAN_CORRUPT_AUTHORITY'
| 'PLAN_SESSION_CAS_CONFLICT'
| 'PLAN_SESSION_DECISIONS_MISMATCH'
| 'PLAN_SESSION_RECOVERY_REQUIRED'
| 'PLAN_NEEDS_RECONCILIATION'
| 'PLAN_DURABILITY_FAILED'