取证覆盖校验先折叠重复分页,并让 Fast GDD 尽量一页读完

run 21 的 GDD 是 133 行,而 file.read 的默认 maxLines 是 120,于是被迫分成
1-120 / 121-133 两页。模型把第二页读了两遍,把三个 actionId 全提交上去,
validate_fast_gdd_file_read_coverage 排序后严格走 `start_line == next_line`:

  1-120   → next_line = 121  ✓
  121-133 → next_line = 134  ✓
  121-133 → 121 != 134       ✗「必须从第 1 行无缺口、无重叠地覆盖到文件末尾」

连吃三次 agent.acceptance_update 拒绝,第四次才猜对该交哪两个 id。

同一页读两遍不削弱证据,不该判成重叠。覆盖检查前先按完全相同的
(startLine, endLine, contentSha256) 折叠——内容 SHA 在上一步已经要求全体一致,
折叠掉的确实是同一页的重复回执。真缺口与部分重叠照旧拒绝,有用例钉住。

顺带把取证指令和 playbook 第 5 步改成「每次都传 maxLines=240(上限),尽量一页
读完;确实需要第二页时从上一页的下一行开始,不要重复读同一段」,让典型 GDD 根本
不进分页逻辑。

另记一笔本次排查暴露的可观测性缺口(未修):agent.acceptance_update 的拒绝理由只
回灌给模型,durable receipt 是 detailUnavailable=true / safeDetail=null,事后在
日志和 agent.db 里都查不到,这类拒绝无法复盘。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 09:10:16 +00:00
parent 0ab887c157
commit 95d6068860
3 changed files with 59 additions and 2 deletions
@@ -4,7 +4,7 @@
2. 冻结后立即用一次 `agent.delegate` 把任务委派给 `project-planning``expectedArtifacts``game/fast_gdd.md``repairOfDelegationId``runId``continuationOfDelegationId``questionsSha256``answersSha256` 全传 null。已有委派尚未收束时不要重复委派。
3. 等待子 Agent 期间不得调用 `respond_to_user`。Runtime 会通过 delegate 完成屏障保持同一父 run,回执到达后再继续。
4. 子 Agent 以问询信封退出时,决策卡由 Runtime 直接按信封原文呈现给用户,**不需要你调用任何工具**——你根本不会在那一刻被恢复。用户答完之后你才会拿到答案,届时为该原 delivery 创建且仅创建一次 continuation 委派,`continuationOfDelegationId``repairOfDelegationId` 都指向该原 delivery,并提交 observation 给出的 `questionsSha256``answersSha256`
5. 回执 contractStatus=evidence-ready 且 GDD 已提交时,用 `file.read` 从第 1 行分页读到 `game/fast_gdd.md` 末尾取证。每次 `file.read` 的 observation 末尾都带着自己的 `sourceActionId`,直接把这些 id 收集起来,用一次 `agent.acceptance_update` 完整写进 evidence 即可——不要为了取 id 再去查动作历史。取证完成前审批卡不会出现。
5. 回执 contractStatus=evidence-ready 且 GDD 已提交时,用 `file.read` 从第 1 行读到 `game/fast_gdd.md` 末尾取证,每次都传 `maxLines: 240`(上限),尽量一页读完;确实需要第二页时从上一页的下一行开始,不要重复读同一段。每次 `file.read` 的 observation 末尾都带着自己的 `sourceActionId`,直接把这些 id 收集起来,用一次 `agent.acceptance_update` 完整写进 evidence 即可——不要为了取 id 再去查动作历史。取证完成前审批卡不会出现。
6. 用户在审批卡上选择修改或退回时,先用 `agent.run_status` 按原 delegationId 取回已认领的权威委派合同,把其中的 acceptanceCriteria 与 expectedArtifacts 逐字照抄进返工委派(`runId` 传 null),再把用户原话完整附在 task 里;同一原委派只能返工一次。用户通过后只做一句简短收尾。
【转达的规则】
@@ -392,7 +392,7 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
)
} else if plan_root {
format!(
"{prompt}\n\n立项策划 Goal Contract 已冻结且不可重写。先完成 project-planning 委派;收到其 Fast GDD 提交后,由当前 Supervisor 根 Run 从 startLine=1 开始分页、无缺口且无重叠地读取 game/fast_gdd.md 直到 EOF,各页必须来自同一内容 SHA-256,并在 agent.acceptance_update.evidence 中提交全部分页 file.read 的 actionId——每次 file.read 的 observation 末尾都带着自己的 sourceActionId,直接用它,不要为取得 actionId 额外查询动作历史。取证未通过时不得展示或创建审批卡,只能针对原策划 delivery 返工。"
"{prompt}\n\n立项策划 Goal Contract 已冻结且不可重写。先完成 project-planning 委派;收到其 Fast GDD 提交后,由当前 Supervisor 根 Run 从 startLine=1 开始分页、无缺口且无重叠地读取 game/fast_gdd.md 直到 EOF每次都用 maxLines=240(上限)以尽量一页读完、少分页,各页必须来自同一内容 SHA-256,并在 agent.acceptance_update.evidence 中提交全部分页 file.read 的 actionId——每次 file.read 的 observation 末尾都带着自己的 sourceActionId,直接用它,不要为取得 actionId 额外查询动作历史。取证未通过时不得展示或创建审批卡,只能针对原策划 delivery 返工。"
)
} else if root_control_authority {
format!(
@@ -349,6 +349,17 @@ fn validate_fast_gdd_file_read_coverage(
return Err("Fast GDD file.read evidence 不属于同一 Markdown 内容".to_string());
}
coverage.sort_by_key(|item| (item.start_line, item.end_line));
// 同一页被读了两遍不削弱证据,却会让下面那趟严格的 `start_line == next_line`
// walk 判成重叠:实测一次 133 行的 GDD 分成 1-120 / 121-133 两页,模型把第二页
// 读了两次、把三个 actionId 全提交上来,于是连吃三次「必须从第 1 行无缺口、无
// 重叠地覆盖到文件末尾」,试到第四次才猜对该交哪两个。这里先按完全相同的
// (startLine, endLine, contentSha256) 折叠——内容 SHA 在上面已经要求全体一致,
// 所以折叠掉的确实是同一页的重复回执,不是两段不同内容。
coverage.dedup_by(|left, right| {
left.start_line == right.start_line
&& left.end_line == right.end_line
&& left.content_sha256 == right.content_sha256
});
if total_lines == 0 {
if coverage.len() != 1 || coverage[0].start_line != 0 || coverage[0].end_line != 0 {
return Err("空 Fast GDD 的 file.read evidence 覆盖不唯一".to_string());
@@ -1218,6 +1229,52 @@ pub(crate) fn render_game_creator_agent_runtime_acceptance_graph_for_prompt_at(
mod tests {
use super::*;
fn page(start: usize, end: usize, total: usize) -> AcceptanceEvidenceReceipt {
AcceptanceEvidenceReceipt {
tool: "file.read".to_string(),
project_revision_before: 1,
project_revision_after: 1,
input_summary: Some(format!(
"path=game/fast_gdd.md · startLine={start} · maxLines=240"
)),
safe_detail: Some(
serde_json::json!({
"path": "game/fast_gdd.md",
"contentSha256": "b".repeat(64),
"lines": format!("{start}-{end} of {total}"),
})
.to_string(),
),
}
}
/// 同一页被读了两遍不削弱证据。实测 133 行的 GDD 被默认 maxLines=120 逼成两页,
/// 模型把第二页读了两次并提交了三个 actionId,于是连吃三次「无缺口、无重叠」。
#[test]
fn repeating_an_identical_page_is_not_an_overlap() {
let first = page(1, 120, 133);
let second = page(121, 133, 133);
let duplicate = page(121, 133, 133);
let receipts = vec![&first, &second, &duplicate];
let (sha, total) =
validate_fast_gdd_file_read_coverage(&receipts).expect("duplicate page is tolerated");
assert_eq!(sha, "b".repeat(64));
assert_eq!(total, 133);
}
/// 真的缺口与真的重叠照旧拒绝——去重只折叠完全相同的分页。
#[test]
fn real_gaps_and_partial_overlaps_are_still_rejected() {
let first = page(1, 120, 133);
let gapped = page(122, 133, 133);
let receipts = vec![&first, &gapped];
validate_fast_gdd_file_read_coverage(&receipts).expect_err("a real gap must fail");
let overlapping = page(100, 133, 133);
let receipts = vec![&first, &overlapping];
validate_fast_gdd_file_read_coverage(&receipts).expect_err("a partial overlap must fail");
}
fn root_fixture() -> (tempfile::TempDir, PathBuf, AgentRuntimeRunProfileBinding) {
let temporary = crate::tests::canonical_test_tempdir("acceptance-graph-");
let root = temporary.path().join("project");