diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs index 2102a6279..ae5bf8b60 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs @@ -536,8 +536,9 @@ fn approval_terminal_observation_exists_locked( // deterministically from the immutable receipt and must match exactly // before any surviving submit anchor may be cleaned up. let expected_summary = approval_observation(receipt).summary; - let (records, _) = crate::project::read_agent_db_records_bounded(root, 16 * 1024 * 1024)?; - Ok(records.iter().any(|record| { + let (records, scan_truncated) = + crate::project::read_agent_db_records_bounded(root, 16 * 1024 * 1024)?; + let found = records.iter().any(|record| { record.get("recordType").and_then(serde_json::Value::as_str) == Some("agent.runtime.tool_observation") && record.get("agentId").and_then(serde_json::Value::as_str) @@ -556,7 +557,25 @@ fn approval_terminal_observation_exists_locked( && record.get("decision").and_then(serde_json::Value::as_str) == Some("approval") && record.get("summary").and_then(serde_json::Value::as_str) == Some(expected_summary.as_str()) - })) + }); + if found { + return Ok(true); + } + // 尾窗只能证明「在」,不能证明「不在」:`read_agent_db_records_bounded` 保留的是 + // 最新的一段,被丢掉的永远是更旧的记录。所以命中即成立,与截断无关;但一次没命 + // 中的截断扫描是「没看见」,不是「没写过」。把它报成 Ok(false),等于让调用方拿一 + // 次视野缺失当审批从未被消费——已经收口的 run 会被当成恢复缺口反复重放。 + // + // 不确定性必须交回调用方,而不是在这里替它猜: + // `project_generic_submit_observation_locked` 的 Err 分支会把它记成一条具名 + // projection gap 并置 recoveryPending,收束闸的 Err 分支会原样带进 blocker detail。 + if scan_truncated { + return Err(format!( + "Agent DB 尾窗扫描被截断,无法确认 Fast GDD terminal observation:runId={} actionId={}", + receipt.run_id, receipt.pending_action_id + )); + } + Ok(false) } fn project_generic_submit_runtime_observation_locked( @@ -1599,9 +1618,6 @@ fn plan_gdd_decision_audit_state_locked( let expected = plan_gdd_decision_audit_value(receipt)?; let (records, scan_truncated) = crate::project::read_agent_db_records_bounded(root, 16 * 1024 * 1024)?; - if scan_truncated { - return Err("Agent DB 扫描被截断,无法确认 plan GDD decision audit".to_string()); - } let mut found = false; for record in records { if record.get("recordType").and_then(serde_json::Value::as_str) @@ -1623,7 +1639,28 @@ fn plan_gdd_decision_audit_state_locked( } found = true; } - Ok(found) + if found { + return Ok(true); + } + // 这道检查原先放在遍历之前、且不看有没有命中就无条件 Err。agent.db 是纯 append + // JSONL,全仓没有任何 rotation/compaction,尾窗的记录数上限 + // (`AGENT_DB_MAX_BOUNDED_RECORDS`)又远低于写侧的扫描上限,于是任何写够记录的项 + // 目都会在这里一去不回:audit 明明就在窗口里,函数照样报错,收束闸把它翻成 + // needs-reconciliation,文案还指向「identity 不一致」这个完全错误的方向。 + // + // 截断掐的是头,而 audit 是决定那一刻刚落盘的最新记录——命中即成立。只有没命中 + // 时截断才有意义:那时才真的分不清「没写过」和「没看见」。 + // + // 代价是命中且截断时不再能断言唯一性(更旧的冲突副本可能已滑出窗口)。本函数的 + // 契约是「audit 已落盘且看得见的都一致」,不是「全库唯一」;用一个永久停摆去换那 + // 点额外强度不划算。 + if scan_truncated { + return Err(format!( + "Agent DB 尾窗扫描被截断,无法确认 plan GDD decision audit:gddId={} version={}", + receipt.gdd_id, receipt.version + )); + } + Ok(false) } fn plan_gdd_pending_identity_matches( @@ -2185,9 +2222,12 @@ pub(crate) fn plan_gdd_typed_completion_blocker_at_locked( )); } Err(error) => { + // Err 现在有两种来源——identity 冲突,和尾窗截断导致的「无法确认」。标题 + // 只说无法确认,具体原因由 detail 里的 error 原文给出;写死成「identity + // 不一致」会把排查的人按到错误的方向上。 return Some(plan_gdd_completion_blocker( "needs-reconciliation", - "Fast GDD decision audit identity 不一致,不能收束任务", + "Fast GDD decision audit 无法确认,不能收束任务", error, )); } @@ -2288,6 +2328,111 @@ mod tests { let _ = fs::remove_dir_all(root); } + /// 尾窗扫描被截断,不得把「还在窗口里的记录」判成缺失。 + /// + /// agent.db 是纯 append、全仓没有 rotation,而尾窗的记录数上限 + /// (`AGENT_DB_MAX_BOUNDED_RECORDS`)比写侧的扫描上限低两个数量级:项目写够记录之 + /// 后,每一次有界读都带着 `truncated`。截断掐掉的永远是更旧的记录,而审批 audit 和 + /// terminal observation 都是决定那一刻刚落盘的最新记录——命中就必须成立。 + /// + /// 这两处判据原先一个在遍历前无条件 Err、一个直接把截断位丢给 `_`,于是写够记录的 + /// 项目会在收束闸上永久停在 needs-reconciliation,文案还指向「identity 不一致」。 + #[test] + fn truncated_tail_scan_still_confirms_records_inside_the_window() { + use std::io::Write; + + let root = std::env::temp_dir().join(format!( + "genarrative-plan-approval-truncation-{}", + uuid::Uuid::new_v4().simple() + )); + init_local_game_project_at(&root, "project-test-approval", "approval truncation") + .expect("init project"); + let receipt = PlanGddApprovalV1 { + schema_version: PLAN_GDD_APPROVAL_SCHEMA_VERSION.to_string(), + project_id: "project-test-approval".to_string(), + gdd_id: "gdd-00000000-0000-4000-8000-000000000011".to_string(), + version: 1, + fingerprint: format!("sha256-serde-json-v2:{}", "6".repeat(64)), + pending_action_id: "action-0123456789abcdef01234568".to_string(), + action_fingerprint: "7".repeat(64), + approval_request_id: "gdd-approval-00000000-0000-4000-8000-000000000012".to_string(), + response_id: "gdd-response-00000000-0000-4000-8000-000000000013".to_string(), + decision_fingerprint: format!("sha256-serde-json-v2:{}", "8".repeat(64)), + source: PLAN_GDD_APPROVAL_SOURCE.to_string(), + run_profile: AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string(), + run_profile_binding_fingerprint: "9".repeat(64), + session_id: "session-approval-2".to_string(), + run_id: "run-planning-child-2".to_string(), + action: "approve".to_string(), + comment: None, + decided_at_utc: "2026-08-15T00:00:00.000Z".to_string(), + receipt_fingerprint: format!("sha256-serde-json-v2:{}", "a".repeat(64)), + }; + + // 先把尾窗撑过截断线,再落审批记录:这样待证记录一定还在窗口里,而扫描一定是 + // 截断的——正是「无法证明不存在」被误当成「不存在」的那个状态。填充走 append, + // 不覆盖 init 已经写下的内容。 + let agent_db = root.join(".agent/agent.db"); + fs::create_dir_all(agent_db.parent().expect("agent db parent")).expect("agent db parent"); + let filler = (0..=crate::project::AGENT_DB_MAX_BOUNDED_RECORDS) + .map(|index| { + format!("{{\"recordType\":\"planning-truncation-filler\",\"i\":{index}}}\n") + }) + .collect::(); + fs::OpenOptions::new() + .create(true) + .append(true) + .open(&agent_db) + .expect("open agent db fixture") + .write_all(filler.as_bytes()) + .expect("pad agent db past the bounded record window"); + + crate::project::append_agent_db_plan_gdd_decision_if_missing( + &root, + plan_gdd_decision_audit_value(&receipt).expect("build decision audit"), + ) + .expect("append decision audit"); + let observation = approval_observation(&receipt); + append_agent_db_terminal_observation_if_missing_for_action( + &root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &receipt.run_id, + &receipt.pending_action_id, + serde_json::json!({ + "recordType": "agent.runtime.tool_observation", + "agentId": GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + "taskId": "task-planning-1", + "runId": receipt.run_id, + "actionId": receipt.pending_action_id, + "actionFingerprint": receipt.action_fingerprint, + "tool": PLAN_GDD_APPROVAL_TOOL, + "status": "ok", + "summary": observation.summary, + "decision": "approval", + }), + ) + .expect("append terminal observation"); + + assert!( + crate::project::read_agent_db_records_bounded(&root, 16 * 1024 * 1024) + .expect("bounded read") + .1, + "fixture 必须真的把尾窗撑到截断,否则这条用例什么都没测" + ); + assert!(plan_gdd_decision_audit_state_locked(&root, &receipt) + .expect("decision audit 命中时不得因截断报错")); + assert!(approval_terminal_observation_exists_locked(&root, &receipt) + .expect("terminal observation 命中时不得因截断报错")); + + // 真的没命中时,截断扫描只能报「无法确认」。报成 Ok(false) 会让调用方拿一次视 + // 野缺失当恢复缺口,反复重放一个其实已经收口的审批。 + let mut absent = receipt.clone(); + absent.run_id = "run-planning-child-absent".to_string(); + assert!(approval_terminal_observation_exists_locked(&root, &absent).is_err()); + + let _ = fs::remove_dir_all(root); + } + /// 祖先绑定坏掉不能让一个跟立项策划无关的 supervisor run 被判成「策划根身份不明」。 /// /// `plan_root_completion_identity_at` 读绑定走的是会遍历完整父链的入口,而识别 diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs index 7a26ef189..4cf39b6e0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs @@ -53,7 +53,12 @@ const AGENT_DB_MAX_ORDINARY_APPEND_RECORDS: usize = AGENT_DB_MAX_SCAN_RECORDS - 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; +/// 尾窗一次最多返回的记录数。超出即 `truncated`,丢的永远是更旧的记录。 +/// +/// 这个上限比写侧的 `AGENT_DB_MAX_SCAN_RECORDS` 低两个数量级,所以「读得到」的窗口 +/// 远小于「写得进」的容量;任何拿尾窗证明「记录不存在」的判据都要自己处理这段差。 +/// `pub(crate)` 是给判据的回归测试用的——测试要能把 fixture 精确撑过这条线。 +pub(crate) const AGENT_DB_MAX_BOUNDED_RECORDS: usize = 16_384; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) enum AgentDbRecordAppendClass {