尾窗截断不再被当成记录缺失,策划收束闸不会再永久停摆
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:
+153
-8
@@ -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::<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 {
|
||||
|
||||
Reference in New Issue
Block a user