From db37f404e2851ad100b0746d12f0c8dc9d337e4a Mon Sep 17 00:00:00 2001 From: Linghong Date: Tue, 25 Aug 2026 02:48:38 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=E5=B0=BE=E7=AA=97=E6=88=AA=E6=96=AD?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E8=A2=AB=E5=BD=93=E6=88=90=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E7=BC=BA=E5=A4=B1=EF=BC=8C=E7=AD=96=E5=88=92=E6=94=B6=E6=9D=9F?= =?UTF-8?q?=E9=97=B8=E4=B8=8D=E4=BC=9A=E5=86=8D=E6=B0=B8=E4=B9=85=E5=81=9C?= =?UTF-8?q?=E6=91=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agent.db 是纯 append、全仓没有 rotation,而有界尾窗的记录数上限(16384)比写侧 的扫描上限(100 万)低两个数量级。截断掐掉的永远是更旧的记录,所以「命中」与截 断无关;只有「没命中」时才真的分不清「没写过」和「没看见」。 - plan_gdd_decision_audit_state_locked:截断判断从遍历前的无条件 Err 挪到遍历后、 且只在没命中时报。原先任何写够记录的项目都会永久停在 needs-reconciliation—— audit 明明就在窗口里,函数照样报错,文案还指向「identity 不一致」。 - approval_terminal_observation_exists_locked:不再把截断位丢给 `_`。没命中且截 断时返回 Err 而不是 Ok(false),交给调用方既有的具名 projection gap 分支,不再 让一次视野缺失冒充恢复缺口被反复重放。 - 收束闸 Err 分支标题改为「Fast GDD decision audit 无法确认」。Err 现在有 identity 冲突和尾窗截断两种来源,具体原因由 detail 里的 error 原文给出。 - AGENT_DB_MAX_BOUNDED_RECORDS 改 pub(crate),让回归测试能精确把 fixture 撑过截 断线,不必在测试里埋魔数。 新增 truncated_tail_scan_still_confirms_records_inside_the_window 钉住这两处:把 任一处改回旧行为都会让它失败。 recovery_scan.rs 的七槽审计是同样的形状,但它断言的是精确基数而非存在性,截断确 实证不了基数,另行处理。 Co-Authored-By: Claude Opus 5 --- .../runtime_protocol/planning_approval.rs | 161 +++++++++++++++++- .../src-tauri/src/project/agent_db.rs | 7 +- 2 files changed, 159 insertions(+), 9 deletions(-) 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 { -- 2.52.0 From 33ee790b9249ab1fbd5aede7a3686bb1dc379243 Mon Sep 17 00:00:00 2001 From: Linghong Date: Tue, 25 Aug 2026 03:50:54 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=E5=AE=A1=E6=89=B9=E5=88=A4=E6=8D=AE?= =?UTF-8?q?=E6=94=B9=E8=B5=B0=E5=85=A8=E9=87=8F=E6=89=AB=E6=8F=8F=EF=BC=8C?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E6=8B=BF=E6=9C=89=E7=95=8C=E5=B0=BE=E7=AA=97?= =?UTF-8?q?=E8=AF=81=E6=98=8E=E3=80=8C=E4=B8=8D=E5=AD=98=E5=9C=A8=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一版把尾窗的截断位改成 fail-closed,方向错了。这次连同它一起修掉根因。 新增 read_agent_db_records_matching:只读流式全量扫描,上限与写侧幂等扫描完全一致 (256 MiB / 100 万条)。读得到的范围从此等于写得进的范围,尾窗那道两个数量级的缺口 在这两条判据上不复存在。 - approval_terminal_observation_exists_locked 改走全量,恢复返回 Ok(found)。它在 project_generic_submit_observation_locked 里是「这次是不是重放」的探针,首次审批时 false 就是正确答案,紧接着才会去写 terminal observation。上一版在那里报 Err,`?` 直接抛出,append_agent_db_terminal_observation_if_missing_for_action 永远到不了, 调用方置 recoveryPending 后重试同构——审批永久停摆。而且 truncated 也会被崩溃残留 的残缺末行置位,而有界读不修尾巴,所以任意大小的项目都能踩中。 - plan_gdd_decision_audit_state_locked 改走全量。它的过滤器只到 (gddId, version), 比写侧幂等键 (gddId, version, responseId) 少一格,是故意的:同版本不同 responseId 的两条 audit 写侧不拦、能共存,这里就是那个跨 responseId 的冲突检测器。上一版靠 「命中即成立」跳过截断,等于让滑出窗口的旧冲突隐形,收束闸会错误放行。 用例改名 approval_judgements_scan_the_whole_agent_db_not_just_the_tail_window,把冲 突副本放在填充之前(窗口之外),并自检尾窗确实截断、副本确实在窗口外。两条改动分别 退回旧行为都会让它失败。 Co-Authored-By: Claude Opus 5 --- .../runtime_protocol/planning_approval.rs | 173 +++++++++--------- .../src-tauri/src/project/agent_db.rs | 93 ++++++++++ 2 files changed, 178 insertions(+), 88 deletions(-) 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 ae5bf8b60..27c300f3d 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,9 +536,13 @@ 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, scan_truncated) = - crate::project::read_agent_db_records_bounded(root, 16 * 1024 * 1024)?; - let found = records.iter().any(|record| { + // 走全量扫描,不走有界尾窗。这条判据的两个调用点语义并不相同: + // `project_generic_submit_observation_locked` 在 pending 还在时用它当「这次是不是 + // 重放」的探针,首次审批时 `false` 就是正确答案,紧接着才会去写 terminal + // observation。尾窗在没命中时只能二选一,而两个都是错的——报 false 会让真正的重放 + // 被当成未消费,报 Err 会把首次审批拦在写 observation 之前,重试同构,永久停摆。 + // 所以答案必须精确:扫全量,`false` 就真的是没写过。 + let matches = crate::project::read_agent_db_records_matching(root, 8, |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) @@ -557,25 +561,8 @@ 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) + })?; + Ok(!matches.is_empty()) } fn project_generic_submit_runtime_observation_locked( @@ -1616,19 +1603,21 @@ fn plan_gdd_decision_audit_state_locked( receipt: &PlanGddApprovalV1, ) -> Result { let expected = plan_gdd_decision_audit_value(receipt)?; - let (records, scan_truncated) = - crate::project::read_agent_db_records_bounded(root, 16 * 1024 * 1024)?; + // 过滤器只到 (recordType, gddId, version),比写侧的幂等键 + // (recordType, gddId, version, responseId) 少一格——这是故意的:同版本不同 + // responseId 的两条 audit 写侧根本不拦,能共存,而这里就是那个跨 responseId 的冲 + // 突检测器。所以扫描范围必须是全量:有界尾窗只要把更旧的那条冲突记录挤出去,收束 + // 闸就只看得见新的那条,然后放行。 + let records = crate::project::read_agent_db_records_matching(root, 64, |record| { + record.get("recordType").and_then(serde_json::Value::as_str) + == Some(PLAN_GDD_APPROVAL_DECISION_AUDIT_RECORD_TYPE) + && record.get("gddId").and_then(serde_json::Value::as_str) + == Some(receipt.gdd_id.as_str()) + && record.get("version").and_then(serde_json::Value::as_u64) + == Some(receipt.version as u64) + })?; let mut found = false; for record in records { - if record.get("recordType").and_then(serde_json::Value::as_str) - != Some(PLAN_GDD_APPROVAL_DECISION_AUDIT_RECORD_TYPE) - || record.get("gddId").and_then(serde_json::Value::as_str) - != Some(receipt.gdd_id.as_str()) - || record.get("version").and_then(serde_json::Value::as_u64) - != Some(receipt.version as u64) - { - continue; - } let mut comparable = record; if let Some(object) = comparable.as_object_mut() { object.remove("schemaVersion"); @@ -1639,28 +1628,7 @@ fn plan_gdd_decision_audit_state_locked( } found = true; } - 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) + Ok(found) } fn plan_gdd_pending_identity_matches( @@ -2328,24 +2296,28 @@ mod tests { let _ = fs::remove_dir_all(root); } - /// 尾窗扫描被截断,不得把「还在窗口里的记录」判成缺失。 + /// 两条审批判据必须扫全量 agent.db,不能只看有界尾窗。 /// - /// agent.db 是纯 append、全仓没有 rotation,而尾窗的记录数上限 - /// (`AGENT_DB_MAX_BOUNDED_RECORDS`)比写侧的扫描上限低两个数量级:项目写够记录之 - /// 后,每一次有界读都带着 `truncated`。截断掐掉的永远是更旧的记录,而审批 audit 和 - /// terminal observation 都是决定那一刻刚落盘的最新记录——命中就必须成立。 + /// 尾窗保留的是最新的一段,能证明「在」,证明不了「不在」,也看不见滑出窗口的更旧 + /// 记录。两条判据都受不了这种半盲的答案,但受不了的方式相反: /// - /// 这两处判据原先一个在遍历前无条件 Err、一个直接把截断位丢给 `_`,于是写够记录的 - /// 项目会在收束闸上永久停在 needs-reconciliation,文案还指向「identity 不一致」。 + /// - terminal observation 判据在 `project_generic_submit_observation_locked` 里当 + /// 「这次是不是重放」的探针用,首次审批时必须拿到可信的 `false`,紧接着才会去写 + /// observation。在那里 fail closed 会把首次审批拦在写之前,重试同构,永久停摆。 + /// - decision audit 判据的过滤器只到 (gddId, version),比写侧幂等键少一个 + /// responseId,就是为了抓同版本不同 responseId 的冲突副本;旧冲突一旦被挤出窗口, + /// 收束闸就会错误放行。 + /// + /// 所以 fixture 把冲突副本放在填充**之前**(窗口之外),把正常记录放在填充之后。 #[test] - fn truncated_tail_scan_still_confirms_records_inside_the_window() { + fn approval_judgements_scan_the_whole_agent_db_not_just_the_tail_window() { use std::io::Write; let root = std::env::temp_dir().join(format!( - "genarrative-plan-approval-truncation-{}", + "genarrative-plan-approval-fullscan-{}", uuid::Uuid::new_v4().simple() )); - init_local_game_project_at(&root, "project-test-approval", "approval truncation") + init_local_game_project_at(&root, "project-test-approval", "approval full scan") .expect("init project"); let receipt = PlanGddApprovalV1 { schema_version: PLAN_GDD_APPROVAL_SCHEMA_VERSION.to_string(), @@ -2368,16 +2340,25 @@ mod tests { decided_at_utc: "2026-08-15T00:00:00.000Z".to_string(), receipt_fingerprint: format!("sha256-serde-json-v2:{}", "a".repeat(64)), }; + // 另一个 GDD:它下面会有两条 responseId 不同的 audit。写侧的幂等键带 responseId, + // 两条都能落盘;判据的过滤器不带,所以它必须把这一对认成冲突。 + let mut conflicted = receipt.clone(); + conflicted.gdd_id = "gdd-00000000-0000-4000-8000-000000000021".to_string(); + conflicted.response_id = "gdd-response-00000000-0000-4000-8000-000000000023".to_string(); + let mut stale = conflicted.clone(); + stale.response_id = "gdd-response-00000000-0000-4000-8000-000000000024".to_string(); - // 先把尾窗撑过截断线,再落审批记录:这样待证记录一定还在窗口里,而扫描一定是 - // 截断的——正是「无法证明不存在」被误当成「不存在」的那个状态。填充走 append, - // 不覆盖 init 已经写下的内容。 + crate::project::append_agent_db_plan_gdd_decision_if_missing( + &root, + plan_gdd_decision_audit_value(&stale).expect("build stale decision audit"), + ) + .expect("append stale decision audit"); + + // 填充把上面那条冲突副本挤出尾窗。走 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") - }) + let filler = (0..crate::project::AGENT_DB_MAX_BOUNDED_RECORDS + 64) + .map(|index| format!("{{\"recordType\":\"planning-fullscan-filler\",\"i\":{index}}}\n")) .collect::(); fs::OpenOptions::new() .create(true) @@ -2387,11 +2368,13 @@ mod tests { .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"); + for decided in [&receipt, &conflicted] { + crate::project::append_agent_db_plan_gdd_decision_if_missing( + &root, + plan_gdd_decision_audit_value(decided).expect("build decision audit"), + ) + .expect("append decision audit"); + } let observation = approval_observation(&receipt); append_agent_db_terminal_observation_if_missing_for_action( &root, @@ -2413,22 +2396,36 @@ mod tests { ) .expect("append terminal observation"); - assert!( + // fixture 自检:尾窗必须真的截断,且冲突副本必须真的在窗口之外。少了这两条, + // 下面的断言在旧实现上也能过,用例就什么都没证明。 + let (window, window_truncated) = crate::project::read_agent_db_records_bounded(&root, 16 * 1024 * 1024) - .expect("bounded read") - .1, - "fixture 必须真的把尾窗撑到截断,否则这条用例什么都没测" + .expect("bounded read"); + assert!(window_truncated, "fixture 必须真的把尾窗撑到截断"); + assert!( + !window.iter().any(|record| record + .get("responseId") + .and_then(serde_json::Value::as_str) + == Some(stale.response_id.as_str())), + "冲突副本必须落在尾窗之外,否则证明不了判据扫的是全量" ); - 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) 会让调用方拿一次视 - // 野缺失当恢复缺口,反复重放一个其实已经收口的审批。 + assert!(plan_gdd_decision_audit_state_locked(&root, &receipt) + .expect("无冲突的 decision audit 必须直接成立")); + assert!( + plan_gdd_decision_audit_state_locked(&root, &conflicted).is_err(), + "滑出尾窗的同版本冲突 audit 仍然必须被抓到" + ); + assert!(approval_terminal_observation_exists_locked(&root, &receipt) + .expect("已落盘的 terminal observation 必须认得出来")); + + // 没写过就是没写过:这里必须是可信的 Ok(false),不能是 Err。首次审批走的正是 + // 这条路,在这里 fail closed 会把 append_agent_db_terminal_observation_... + // 拦在后面,重试同构,审批永久停在 recoveryPending。 let mut absent = receipt.clone(); absent.run_id = "run-planning-child-absent".to_string(); - assert!(approval_terminal_observation_exists_locked(&root, &absent).is_err()); + assert!(!approval_terminal_observation_exists_locked(&root, &absent) + .expect("首次审批探针不得因为扫描范围报错")); let _ = fs::remove_dir_all(root); } 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 4cf39b6e0..b9e2f985e 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 @@ -3253,6 +3253,99 @@ pub(crate) fn read_agent_db_records_bounded( Ok((records.into_iter().collect(), truncated)) } +/// 全量扫描 Agent 本地索引,返回全部命中 `predicate` 的记录。 +/// +/// 有界尾窗(`read_agent_db_records_bounded`)保留的是最新的一段:它能证明「在」, +/// 证明不了「不在」,也看不见已经滑出窗口的更旧记录。凡是要拿扫描结果做 fail-closed +/// 判据的调用方——「这条回执消费过没有」「这个 (gddId, version) 下有没有第二条冲突 +/// audit」——都必须走这条路。用尾窗做这种判据只有两种输出,而两种都是错的:命不中就 +/// 报「不存在」会把视野缺失当成事实,命不中就报错会把首次写入拦在写之前。 +/// +/// 扫描上限与写侧的幂等扫描完全一致(`AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES` / +/// `AGENT_DB_MAX_SCAN_RECORDS`),所以只要追加还写得进去,这里就一定扫得完;不会出现 +/// 「写得进但读不到」的窗口——那正是尾窗留下的那道两个数量级的缺口。 +/// +/// `max_matches` 是命中数上限,超出报错而不是静默截断:判据宁可停,也不能拿一个不完 +/// 整的命中集合下结论。 +pub(crate) fn read_agent_db_records_matching( + root: &Path, + max_matches: usize, + predicate: impl Fn(&serde_json::Value) -> bool, +) -> Result, String> { + let path = root.join(".agent/agent.db"); + let Some(directory) = open_agent_db_directory(root, false)? else { + return Ok(Vec::new()); + }; + let append_lock = project_append_lock_for(&path)?; + let _append_guard = append_lock.lock_process("Agent 本地索引")?; + verify_agent_db_directory_current(&directory)?; + let Some(mut storage) = open_agent_db_storage(directory, false, false)? else { + return Ok(Vec::new()); + }; + let length = storage + .file + .metadata() + .map_err(|error| { + format!( + "读取 Agent 本地索引元数据失败:{}: {error}", + storage.path.display() + ) + })? + .len(); + if length > AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES { + return Err(format!( + "Agent 本地索引超过 {} 字节扫描上限:{}", + AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES, + storage.path.display() + )); + } + storage.file.seek(SeekFrom::Start(0)).map_err(|error| { + format!( + "定位 Agent 本地索引失败:{}: {error}", + storage.path.display() + ) + })?; + let mut reader = BufReader::new(&mut storage.file); + let mut matches = Vec::new(); + let mut record_count = 0usize; + while let Some(line) = read_agent_db_jsonl_line_bounded(&mut reader, &storage.path)? { + // 崩溃留下的残缺末行从未提交成功,写侧下一次追加会把它截掉。它不是记录,也不 + // 该让判据 fail closed——扫到这里停住就够了。 + if !line.complete { + break; + } + if line.content.iter().all(|byte| byte.is_ascii_whitespace()) { + continue; + } + record_count = record_count.saturating_add(1); + if record_count > AGENT_DB_MAX_SCAN_RECORDS { + return Err(format!( + "Agent 本地索引超过 {} 条记录扫描上限:{}", + AGENT_DB_MAX_SCAN_RECORDS, + storage.path.display() + )); + } + let record = + serde_json::from_slice::(&line.content).map_err(|error| { + format!( + "解析 Agent 本地索引失败:{}: {error}", + storage.path.display() + ) + })?; + if !predicate(&record) { + continue; + } + if matches.len() >= max_matches { + return Err(format!( + "Agent 本地索引命中记录超过 {max_matches} 条上限:{}", + storage.path.display() + )); + } + matches.push(record); + } + Ok(matches) +} + pub(crate) fn read_agent_db_action_receipts_by_identities( root: &Path, identities: &BTreeSet<(String, String, String)>, -- 2.52.0