尾窗截断不再被当成记录缺失,策划收束闸不会再永久停摆
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

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 02:48:38 +00:00
parent 9f8a24fc23
commit db37f404e2
2 changed files with 159 additions and 9 deletions
@@ -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 observationrunId={} 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 auditgddId={} 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::<String>();
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` 读绑定走的是会遍历完整父链的入口,而识别
@@ -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 {