Files
knowledge-governance/docs/agents/traps.md
姚章浩 c2ab658033
build / build (push) Successful in 1m19s
docs: 记录写集合证据被整份覆盖的陷阱
新增 trap 20:复用预备份时重新造一份证据,会让已登记的写集合锚点被整份
抹掉(现场 backup_bytes:0 + writeset_* 全丢)。写明症状、根因、负例优先的
判据与修法,含预备份版本行「一条只属于首次登记的作业」这一归属语义。
AGENTS.md 索引表那一行受写入保护、需主人批准,本次未改(见交接单待批准项)。
2026-10-30 09:14:14 +08:00

35 KiB
Raw Permalink Blame History

已知陷阱(全文)

这份文件是 AGENTS.md 第 4 节那张索引的全文。拆成两份是有原因的: AGENTS.md 作为"工作区指令"会被自动加载,而指令有字节预算 —— 超出的部分会被 静默截断,没有任何报错。索引留在指令文件里(一行一条,一眼扫到),全文放这里。

纪律:只追加、不重排、不复用旧号 —— 编号会被代码注释、测试、文档引用(形如"见 trap-3")。 新增一条要改两处:这里加全文 + AGENTS.md 的索引表加一行。

trap-1:.gitignore 对已入库的文件无效

症状:规则明明写了 __pycache__/,git ls-files 里却仍有 8 个 scripts/__pycache__/*.pyc。

根因:.gitignore 只影响"未跟踪"的文件。文件一旦进了索引,规则对它就完全不起作用, 而且不会有任何提示。

判据:git ls-files | grep -E '__pycache__/|\.pyc$' 输出非空即是这条。

修法:规则 + git rm -r --cached <path>(保留磁盘文件)。 规则齐全不等于没被跟踪 —— 断言要查两件事:规则在、且 git ls-files 里没有。

反例:只加 .gitignore 规则就以为修好了。下次 git add -A 时它照样进来。

trap-2:git rm --cached 之后跑 git add -A,文件会被搬回索引

症状:执行了 git rm --cached node_modules,git status 也显示删除了, 提交后 git ls-files 里那个文件仍在,净效果为零。

根因:--cached 只从索引移除、磁盘文件留着。如果此刻忽略规则还没生效 (本仓当时只写了 /web/node_modules/,管不到根级 node_modules), 紧接着的 git add -A 会把这个"存在且未被忽略"的文件重新加回索引。

判据:git rm --cached X 后、提交前,先跑 git check-ignore -v X —— 必须命中一条规则,否则 add -A 会把它捡回来。

修法:先让忽略规则生效,再撤出索引,并且此后只 git add <具体文件>, 不要用 -A。

反例:把两步写成一条 git rm --cached X && git add -A && git commit。

trap-3:用子串判断代替整行判断

症状:脚本判断"规则已存在"时写 if "/node_modules/" not in 全文, 而文件里已有 /web/node_modules/ —— 它包含这个子串,于是误判为"已存在", 跳过了追加。结果规则根本没加上,而脚本打印"规则已存在"。

根因:.gitignore 的 /web/node_modules/ 与 /node_modules/ 语义完全不同 (前者只管 web/ 下),但作为字符串是包含关系。

判据:任何"这条规则在不在"的判断,都要对行集合做精确比较 ({l.strip() for l in lines}),不要对全文做 in。

反例:if "xxx" not in text 这类判断在多条相似规则并存时必然出错。

trap-4:Gitea 合并接口成功时返回空 body

症状:脚本调用合并接口,HTTP 200,但解析 JSON 抛 json.decoder.JSONDecodeError: Expecting value: line 1 column 1, 看起来像"合并失败",于是重试 —— 而 PR 其实已经合并了。

根因:Gitea 的 POST /pulls/{n}/merge 成功时 body 为空(不是 {"merged":true})。 把"HTTP 200 + 空 body"当成失败是客户端的问题,不是服务端的问题。

判据:合并后 不要信 POST 的返回,改 GET /pulls/{n}, 要求 state=closed + merged=true + merge_commit_sha 非空。

反例:在合并接口上做重试。永不重试 —— 会得到"第二次合并失败"的假故障。

trap-5:浅克隆里 git describe --tags 会静默退化(≠ 不能用 tag)

症状:想让版本号来自 git describe --tags,在 CI 上失败或给出误导结果。

根因:工作流里是 git fetch -q --depth 50 origin "$BR",浅克隆不带 tag。 git describe 要么报 No names found,要么退回到一个和预期无关的短 sha。 ⚠️ 这是"不要在构建时靠 git describe 取版本号",不是"本仓不用 tag" —— 正式版本 就是用注释 tag vX.Y.Z 标记的(由人批准后创建)。

判据:任何依赖 tag 历史的自动步骤,必须在 CI 里显式 git fetch --tags / --unshallow;否则就是这条。

修法(本仓采用):

  • 版本号的唯一来源是仓库里的 CHANGELOG.md(python3 scripts/version.py),文件一定在,不依赖 tag; 构建身份(含开发标识)用 scripts/version.py --identity,make version 打印它。
  • tag 一致性由人可核验的方式检查:检出步骤专门落一份远端 tag 清单 (.ci-remote-tags)并 git fetch --tags 取对象,scripts/check_changelog.py --tags 再核 「tag 版本 == 它指到那个提交里的版本记录」、且必须是注释 tag; 没有 tag 不判红,但没核对远端就不许说"校验成功"(CI 用 --require-remote 强制)。

反例:VERSION=$(git describe --tags --always) —— 本地看着正常,CI 上静默退化。 反例二:检查器在拿不到远端 tag 清单时照打 ✅ 校验通过 —— 那是"假绿",现在会明确写「未核对远端」。

trap-6:act_runner 不会自动检出代码

症状:工作流第一步就报找不到文件 / 空工作区。

根因:本仓的 act_runner 不会替作业做 git clone,工作区起始为空。

判据:工作流里若没有 git init + git remote add + git fetch + git checkout, 就是这条。

修法:照 .gitea/workflows/build.yml 的「检出代码」步写(带 x-access-token 的 remote,令牌来自 secrets.GITHUB_TOKEN)。

反例:以为 runner 会像 GitHub Actions 那样自动 checkout,直接写业务步骤。

trap-7:grep -q 在 pipefail 下会被 SIGPIPE 谎报失败

症状:echo "$OUT" | grep -q 'PASS' 明明匹配上了,整条命令却返回非 0 (141),在 set -o pipefail 的作业里表现为"检查失败"。

根因:grep -q 一命中就退出并关闭管道,上游 echo 随即收到 SIGPIPE, 整条管道返回 141。

判据:set -o pipefail + ... | grep -q ... 同时出现即是这条。

修法:用 case "$OUT" in *PASS*) ... ;; esac,或让 grep 直接读文件; 非用管道不可时用 set +e; set +o pipefail + ${PIPESTATUS[0]}。

反例:if echo "$x" | grep -q y; then —— 时红时绿,且方向完全错。

trap-8:gofmt -l 列出文件时退出码仍是 0(只看退出码=假绿)

症状:门禁里写着 gofmt -l .(或 step gofmt -l . || fail=1),格式乱掉的文件明明被列出来了, 这一步却通过。整条门禁全绿,而仓库里躺着一堆没格式化的文件。

根因:gofmt -l 的正常行为就是"列出不合规的文件并返回 0" —— 它的退出码表达的是"我跑完了",不是"都合规"。用退出码当判据,等于没判。

判据:任何"输出即问题"的工具(gofmt -l、prettier --list-different、git status --porcelain) 都必须判输出是否为空,不能判退出码。

修法:[ -z "$(gofmt -l <文件...>)" ],或者像本仓 scripts/check_format.py 那样解析输出; 并且给它一条故意弄坏的夹具证明它真的会红(否则"永远返回 0 的校验器"会一路绿灯)。

反例:step gofmt -l . || fail=1 —— 生成器里就是这么写的,一直没人发现。

trap-9:tar -C dir . 产生 . 根成员 ⇒ 预览 404

症状:打包与上传都成功、通知也发了,点开预览链接却是 404 或报错。

根因:tar -czf x.tgz -C dir . 会把 .(以及 ./index.html)写进成员名。 严格的解包器要么拒绝整包,要么解出一层多余的 .,于是 index.html 不在预期的位置上。

判据:tar -tzf <包> 的第一行若是 . 或 ./…,即是这条。

修法:先 cd 进产物目录,再用通配展开成员名(( cd "$WEB" && { shopt -s dotglob; tar -czf "$TAR" *; } )), 并在发之前断言包里真的有 index.html(tar -tzf "$TAR" | grep -qx 'index.html')。

反例:只看"打包成功 / 上传 HTTP 200"就当预览发好了 —— 那两步都不会因为包结构不对而失败。

trap-10:依赖清单被静默改写

症状:一次 CI 通过之后,go.mod / go.sum / package-lock.json 与提交里的不一样了; 本地跑一遍还多了/少了几个依赖,没人改过它们。

根因:两个"顺手"的默认行为 —— GOFLAGS=-mod=mod(构建时自动补全/更新 go.mod)、 以及缺锁文件时回落到 npm install(按 package.json 重算整棵依赖树并重写锁文件)。 两者都让"依赖清单"变成运行时产物,而它本该是人 review 过的契约。

判据:夹具 = 门禁开始前后对这几个文件取 sha256;任何一步改写了它们 ⇒ 红。

修法:GOFLAGS=-mod=readonly;缺锁直接失败(不接受无约束安装); npm ci(按锁装、锁与 package.json 不一致就报错)。见门禁的 deps-install 与 manifests-locked 两项。

反例:if [ -f package-lock.json ]; then npm ci; else npm install; fi —— 看起来"更健壮", 实际是给漂移开了后门。

trap-11:汇总把「未运行 / 被跳过」当「通过」

症状:某个必需检查压根没跑(失败即停的后果、或运行时被 if: 跳过), 汇总却报"全绿";事后看报告,那一项根本不在报告里,谁都没发现。

根因:汇总逻辑只遍历"跑过的项"。没跑的项不出现 ⇒ 集合变小 ⇒ 全绿。 另一种写法是 skipped 与 success 一起放行。

判据:给汇总喂一个"必需项缺失 / 必需项 skipped"的夹具,它必须非绿。

修法:清单里声明必需项集合,汇总按清单(不是按结果数组)逐项核对; 缺项记 not_run 并判红;aggregate() 只认 success。 不适用要在清单里声明(本仓的 required_in),不能在运行时悄悄 skip。

反例:status = "failure" if any(x == "failure" for x in results) else "success"。

trap-12:事件 SHA ≠ 检出 SHA

症状:CI 里跑的是"分支当前 HEAD",而这次运行的事件指向的是另一个提交。 表现是"我刚推的那一步好像没跑",或者更糟 —— 拿新代码的结论去解释旧代码的产物。

根因:工作流写 git fetch --depth 50 origin "$BR" && git checkout -B "$BR" FETCH_HEAD, 取的是当时的分支 HEAD。只要在事件产生之后又有人推了一次(或重跑旧运行),两者就分叉。

判据:git rev-parse HEAD 必须等于 ${GITHUB_SHA}(完整 SHA,不是短 sha)。 不一致 ⇒ 红。

修法:检出后立刻核对;不一致时改按精确 SHA 取(git fetch --depth 50 origin "$EVENT_SHA"), 再取不到就失败。门禁的 identity 项会再核对一次(两道,故意的)。

反例:报告里只写"分支 main 的构建成功" —— 这个结论没有对应的提交。

trap-13:密钥扫描规则过宽 ⇒ 永远的假红

症状:接入密钥扫描后立刻红,命中全是正常代码:postgres://{u.quote(user)}:{u.quote(password)}@host/db (模板表达式)、postgres://governance:***@postgres/governance(脱敏占位)、 X-CI-Token: ${BRIDGE_TOKEN}。

根因:规则用 [^@\s]{6,} 这种"很宽"的字符类去抓口令段,于是把变量插值、脱敏星号、 模板表达式全部当成密钥。假红一旦出现,人就会把整个检查注释掉 —— 那比没有检查更糟。

判据:扫描器必须自带双向夹具:合成的真密钥要命中,良性对照要零误报, 而良性对照里要钉住踩过的误报形状(防回归)。

修法:只收"干净字符"的口令段([A-Za-z0-9+/=_\-]{6,}),并对命中值做占位符判定 (${...}、<...>、***、changeme…)。见 scripts/check_secrets.py 的 RULES 与 --self-test。

反例:为了让门禁变绿去放宽/注释规则 —— 那是降低规则换绿灯,规范 §4.2 明确禁止。


trap-14|"这个语言我没写检查" = 假报成功(通用模板的 Python 门禁空跑)

症状:通用模板套到一个 Python 项目上,deps-install / format / static / build / tests 五项全绿, 但一个 Python 检查都没跑 —— 函数体只打印一句话就 return SUCCESS。

根因:模板最初只为 Go+Node 写实现,Python 分支留了"占位返回成功"。占位返回成功不是"还没做", 是假报成功:它会让人以为这个项目被检查过。审查是靠"模拟一个 Python 项目,看那五项是不是真的执行了" 才发现的 —— 只看 YAML/看代码是看不出来的。

判据:每一门识别到的语言都必须真的执行;工具缺失、依赖没按锁装、输出解析不了 ⇒ 失败。

修法:uv sync --frozen / uv run ruff format --check / compileall / uv run pytest; need() 找不到工具就抛错(转成 FAILURE)。前端同理:测试进程退出码非零必须红 (报告写 pass 不算数),框架按 test-scope.json 声明,认不出来就失败。

trap-15|预览放在"报告核对"之前 = 发出去就收不回来

症状:预览步骤只读 overall_status + commit_sha 就发布;完整报告核对在后面的通知步骤。 报告缺项时,预览已经发出去了,几分钟后才报错 —— 那时 URL 可能已经被人打开/转群。

根因:把"发布"当成"通知"的一环。预览是不可撤回的对外动作,不能排在质量结论之前。

判据:发布前必须调用同一份核对逻辑(ci_report.verify()),有任何问题 ⇒ 一个 publish 请求都不发。

修法:.gitea/scripts/ci_preview.py 内部先核对再发布;scripts/ci_preview_negative_test.py 用假 bridge 数请求证明坏报告 0 次、好报告 1 次。

trap-16|用"观察到的 context"代替"要求哪些检查"

症状:合并前没指定必需 context 时,脚本拿"体检时在该仓看到过哪些状态"当必需列表。

根因:combined state 只报"报过状态的都成功",一个无关的检查成功就能让它变成 success。 于是"没跑过必需检查"的 PR 会被合进去。

判据:要求哪些检查只能来自明确策略(--require-context 或 .gitea/required-contexts.txt); 策略缺失或为空 ⇒ 拒绝合并(空不等于"没有要求")。

修法:见 scripts/adopt-existing.py 的 resolve_required_contexts() 与 --self-test-chain (假 Gitea + 真实 merge_pr(),断言 merge 请求发没发)。同时核对 base 分支/PR 状态/head 仓库。

trap-17|remote 协议被"顺手"拼成 http

症状:注释说"保留服务端给的协议",代码却是 git remote add origin "http://${HOSTPORT}/..." —— 服务端给的是 https 时会静默降级成 http(凭据与产物走明文)。

根因:把 ${SRV#*://} 取出来的 host:port 再拼一个固定的 scheme。取 host 的动作本身就把协议丢了。

判据:remote 直接用 GITHUB_SERVER_URL 原样拼,并断言最终 scheme 与服务端一致; 未知 scheme 直接拒绝;URL 里不许带凭据。

修法:.gitea/scripts/ci_checkout.sh(--print-remote 可本地验证协议保留); YAML 里只调用它,不再内联这套拼接。

trap-18|范围校验回退成"最近 N 条"或"接受截断的列表"

症状:范围算不出来时退化成 git log -n 5(和历史无关),或直接采信事件负载里那串 commits。

根因:事件负载里的 commits 可能是截断的(实测:声明 6 条、列表只给 1 条), 浅克隆里 before 可能根本不存在 —— 两种都会让"本次改动"少核几条,而且全绿。

判据:以准确 Git 范围为准(push 用 before..after,新分支用 after --not --remotes); 事件声明条数与 Git 范围不一致 / 空范围 / 浅克隆取不到 before / 列表提交不在检出里 ⇒ 全部拒绝; 手工运行比不出范围时只核 HEAD,但必须写明。

修法:.gitea/scripts/ci_gate.py 的 resolve_scope()(--self-test 11 个分支)+ 检出脚本顺带把事件 before 取下来。


trap-19|第一步就调用仓内脚本 = 整条流水线起不来

症状:workflow 第一步写 bash .gitea/scripts/ci_checkout.sh。本地"已经有代码的目录"里跑得好好的, 真到 Runner 上返回 127(bash: .gitea/scripts/ci_checkout.sh: 没有那个文件或目录)—— 因为这个 Runner 不自动检出,工作区初始是空的(trap-6),此时仓内脚本还不存在。

根因:把"检出"写成了仓内脚本,而"检出"恰恰是让仓内文件出现的那一步 —— 循环依赖。 本地验证只在有代码的目录里做,就永远看不到这个形状。

判据:第一步必须是最小 bootstrap:只依赖 runner 给的环境变量(GITHUB_SERVER_URL / GITHUB_REPOSITORY / GITHUB_SHA / GITHUB_TOKEN),自己 git init + remote add + 取事件完整 SHA

  • checkout + 断言 HEAD==事件 SHA。检出完成、脚本就位之后,才调用仓内脚本做后续校验与补取。

修法:build.yml 第一步内联 bootstrap → 然后 bash .gitea/scripts/ci_checkout.sh --post-checkout。 scripts/ci_chain_fixture.py 会把那段 run: 原样抽出来在空目录里执行(YAML 一改坏就红)。

trap-20|fetch origin <SHA> 取任意提交(服务端默认不允许)

症状:分支 HEAD 已经前进时,bootstrap 想"按精确 SHA 取",执行 git fetch origin $GITHUB_SHA → 服务器不允许请求未公开的对象(upload-pack 默认不开放按任意 SHA 取)。

根因:把 --depth 取分支的写法套到按 SHA 取上。这个能力在默认配置下不存在。

判据:事件 SHA 必须能从已取到的分支历史里找到;不够深就 --deepen 再找; 仍找不到才是失败(提示提高深度或等重跑)。失败要明确,不许"取不到就换个分支糊过去"。

修法:见 bootstrap 里的 git cat-file -e "${GITHUB_SHA}^{commit}" + fetch --deepen 200 分支。

trap-21|after --not --remotes 在检出后把范围排成空集

症状:新分支推送(before 为全 0)用 after --not --remotes 算范围。CI 检出时 refs/remotes/origin/<本分支> 往往已经等于 after —— 那个引用把自身排除了,范围恒为 0 条, 闸门直接失败(或更糟:被当成"没有要检查的提交"放过去)。

根因:拿"刚 fetch 下来的同名远端分支"当排除集。它是被检出的那个分支,不是基线。

判据:新分支的基准只能是远端默认分支(事件里的 repository.default_branch), 范围 = refs/remotes/origin/<default>..<after>;取不到基准 ⇒ 明确失败,不许退化成整部历史或空集。

修法:ci_gate.resolve_scope();ci_checkout.sh --post-checkout 负责把默认分支引用取下来。 --self-test 里有专门的回归用例(origin/<本分支> == after 时必须仍算出那几条提交)。


trap-22|把事件里的 total_commits 当成"声明条数"

症状:新分支推送的真实事件里 Gitea 给的是 total_commits=0、commits 列表被截断(实测 5 条), 而 Git 范围算出 12 条。严判"声明 ≠ Git 范围"⇒ 门禁在第一次真实 CI 就红了, 红的原因与代码质量无关(本地夹具用的是"声明 6 条/列表 1 条"的自造形状,覆盖不到这一种)。

根因:total_commits 在 Gitea 的语义不是"这次推送的提交条数",新分支推送时就是 0 (等于"没声明")。把它当权威声明去比对,等于用一个不存在的约束判红。

判据:Git 范围是唯一权威。total_commits > 0 时才拿来交叉核对(不一致 ⇒ 拒绝, 覆盖"声明 6 条 / 列表 1 条 / Git 2 条"那一类);<=0 或缺失 ⇒ 视为未声明, 以 Git 范围为准,并把"负载条数不可信、列表被截断"写进来源说明里。

修法:ci_gate.resolve_scope()(--self-test 里有专门的 total_commits=0 分支)。 教训:负例要照真实事件负载的形状造,不能只造自己想的形状。

trap-14:包一层装饰器就丢掉"可选接口"⇒ 写集合台账被静默跳过

症状:真同步作业成功、阶段全绿,但预备份版本证据里没有 writeset_digest, 归档根下也没有 writesets/。崩溃接管时 loadWriteSetForJob 立刻读不到台账, 作业只能失败并把目标留在维护里。

根因:libraryrelease.Run 用类型断言决定要不要在写第一笔之前算写集合 (target.(WriteSetPlanner))。worker 把目标包成 contentTrackedTarget 以标记 "内容可能半写",但这个包装只转发了 Current/Apply/Verify/Restore —— 断言 因此恒为假,整段台账逻辑被静默跳过(没有日志、没有错误)。

判据:任何 *_test.go 里断言 libraryrelease.Target(包装体).(WriteSetPlanner) 必须为真;或直接查真作业的预备份 evidence->>'writeset_digest' 是否非空。

修法:包装层显式转发该方法;内层不支持时返回硬错误,不要退回"没有台账也照写"。 更一般的规则:只要上游靠可选接口(XxxPlanner/XxxObserver)决定行为, 每个包装层都要么转发它,要么让断言失败可见。

反例:只给内层目标写测试(它当然实现),从不测"包装后的目标还实现吗"。

trap-15:Stat/Delete/Copy 与 Open/Put 吃不同的定位符形状

症状:对象明明在桶里,Put 与 Open 都成功,Stat 却报 invalid release object read 或 404。

根因:真实资源路径是 storage://<backend>/<provider>://<bucket>/<key>(嵌套 scheme)。validObjectKey 认这种形状并把它拆成裸 key,ValidObjectLocator 只认单层; Open/Put 用前者,Stat/Delete/Copy 用后者、而且直接把整串当对象键。

判据:集成用例必须喂真实定位符(含 storage:// 前缀与内层桶),负例覆盖 "键不存在 / provider 不符 / 路径穿越 / 复制源不存在"。

修法:所有按路径操作的入口共用同一对函数(validObjectKey + providerPathFromLocator),测试不要只喂裸 key —— 裸 key 会让这条不一致完美隐身。

反例:if _, _, err := store.Stat(...); err == nil { t.Log("...") } —— 读不到也 算通过,等于把断言关掉了。

trap-16:"资源不在预备份范围"不等于"属于别的知识库"

症状:同步 A 成功 → 手工恢复 B(resources 表不删行、对象也不删)→ 再同步同一个 A, 预检在写第一笔之前整次拒绝:

资源 ID 冲突:<id> 存在于目标租户但不在预备份范围内(属于其它知识库),拒绝覆盖

根因:resources 是多知识库共享表,只有 tenant_id;预检把"同租户 + 不在预备份 范围"当成"别人的行"。但恢复 B 的预备份是按当时的绑定/正文引用取范围的,A 写进去、 恢复后变成无绑定的资源不在它里面 —— 这些行恰恰是本目标自己上一次成功同步的结果。 "目标里没人引用"不是归属证据,历史孤儿、其它知识库暂未绑定的资源同样 满足它;按它放行就是允许覆盖别人的行。

判据:真实现场(同步 A → 恢复 B → 再同步 A)必须能完成,且复用资源的 PG xmin 不变、universeDump 里历史孤儿/其它知识库/外租户行逐字节不变;把同一行的内容改一个 字段后必须继续拒绝;没有持久来源时必须继续拒绝。

修法:只走"可审计复用"这一条放行路径(internal/releaseadapter/v071/library_reuse.go): 持久来源(这个目标成功同步过且登记生效版本的快照,或它自己的预备份)+ 整行内容 (除易变时间戳)一致 + 对象字节与归档摘要一致,三者同时成立才允许复用;复用行在写集合 里是 existing+reused,写入阶段跳过(不重写行、不重传对象),恢复也不清理它。 证据单独落盘在 reuse/<digest>.json,摘要在写集合行里 —— 不冒充历史台账。

反例:为了"让同步能过"把"不在预备份范围"直接放行,或者恢复时按"没被引用就删"清理。

trap-17:分页窗口当索引 —— "当前生效版本"不能按最近 N 条找

症状(比 trap-14 更隐蔽:它不报错,只是判错):

  • 接管时把一个"其实已经生效"的作业判成 fresh,于是把已经上线的目标重写一遍;
  • 基线入口为一个已经有权威版本的目标放行"建基线"(会覆盖别人的版本);
  • 页面上"生产当前版本"显示成"没有生效版本记录",同步闸口据此要求先建基线;
  • 只有把窗口 N 从 50 调到 100/200 才"暂时好一点",再长一点又复发。

根因:LibraryVersions(scope, N) 是分页展示用的窗口(真实上限 1..100), 它按 created_at DESC 截断。可"当前生效版本"是每 scope 唯一一行、可能很久以前 登记的事实;"本快照是否生效过"更是身份查询。用窗口当索引,历史一长就把存在 读成不存在——而且读成"没有"不会报错,只会让错误的选择看起来合理。

判据:把生效版本登记在 48 小时前、再灌 250 条更新的版本记录,然后:

  • LibraryVersions(scope, 100) 里看不到它(用例先断言这一点,否则夹具失效);
  • LibraryCurrentVersion(kind='production_effective' AND superseded_at IS NULL) 仍精确命中,接管判定是 pending_final 而不是 fresh;
  • 换个更新的快照生效后,旧快照 LibraryVersionOfSnapshot 仍取回并带 superseded_at,接管判定 superseded(拒绝用旧快照覆盖);
  • 台账被写坏(同一身份两行、未顶替却不是当前生效、已顶替却是当前生效)⇒ 拒绝挑选 / 判 unknown,不许挑一条"看起来对"的继续写。

修法:身份/当前状态一律走精确查询(LibraryCurrentVersion、 LibraryVersionOfSnapshot、LibraryVersionByRef),窗口只用于列表展示; 同一含义的判定(worker 接管、基线、同步闸口的创建判据、归档保留的保护标记) 共用同一份实现,别各写一份"取最近几条再筛"。

反例:把窗口上限调大、或在内存里 for 一遍再 break——那是换了个数字, 不是换了判据。

18. 快照相等 ≠ 本作业完成:接管判据必须绑作业身份

症状(2026-10-29T19:16Z 真实现场):作业只写完了写入链路的第一个阶段 (database,resources/index/wiki/verify 还是 pending),worker 被杀、人工拉起后 又被判成"succeeded / finalize succeeded",阶段证据写"已登记生效版本(仅收尾,未重写生产)", 并且把生效版本的归属改写成这个作业自己。

为什么它看起来是合理的:分类只看一个事实——"当前生效版本的目标快照是不是我的目标快照"。 是 ⇒ pending_final(只差收尾,只做校验+登记)。而这个环境里同一个快照会被多个作业 发布(同一份冻结候选重复同步),于是"生产上有这个快照"被当成"我这个作业写完了"。

根因:library_versions 的主键由 (kind, scope, 快照) 决定 (LibraryVersionID),登记是 ON CONFLICT (id) DO UPDATE,会改写 source_job_id。 所以"快照相等"里没有作业身份这一维;只看快照就永远分不清"我发的"和"别人发的"。

判据(负例优先):

  • 内存:当前生效快照 == 目标快照,但 source_job_id 是别的作业 ⇒ 必须是 foreign_effective(不是 pending_final);把归属改成本作业才允许 pending_final;
  • 真实 PG:先 RecordLibraryVersion 一个 source_job_id=别的作业 的生效行,再 loadLibraryVersionFacts + classifyTakeover,断言 foreign_effective; 再用本作业登记同一快照(upsert 改写归属),断言 pending_final;
  • 撤掉身份判定后这两条必须红(实测:classify = "pending_final")。

修法:effectiveOwnedBy(version, jobID, snapshotID) 一份实现,接管与基线共用; foreign_effective 走"拒绝接管"的失败出口(failLibraryTakeover):不写目标、 不释放维护、终态失败并指明预备份基线——目标可能半写,释放等于谎称已恢复。

演练侧的同一条:不同快照才证明完整续作。目标快照 == 中断前的生效版本时, 脚本在杀之前就拒绝(--allow-same-snapshot 只用于诊断,不算验收证据); 成功判据里必须包含"database/resources/index/wiki/verify 五个阶段全部 succeeded"。

19. 接管证据:汇总日志与阶段文案都不是"这个作业被接管了"

症状(2026-10-29T19:56Z 真实现场):不同快照的 after:database 演练里,作业最终 succeeded、database/resources/index/wiki/verify 全 succeeded、生效版本归属本作业、 维护两把锁都释放了,目标内容也确实恢复到 1/2/9;但演练脚本 exit 5,理由是 "worker 日志里没有本作业的 resume 记录:接管来源不明"。

为什么它看起来是合理的:worker 日志里明明有 library job takeover sweep: 1 resumed, 0 failed without backup。但这行是汇总: 既不说哪个作业,也不说它是否真被本进程认领(另一个 worker 抢先认领、attach 失败 都只影响计数)。脚本要的老文案 library job <id> resumed after worker restart 只在"新建队列行"那条路径打印;真实走的是"队列行已存在"(崩溃前队列行还在)那条, 那条只把计数 +1。

根因:判据绑在一条只在部分路径打印的文案上,而且没有作业身份校验。 另一半是阶段语义:真实接管是"复用崩溃前那份预备份、重跑整条写入链路", release.go 会把 prebackup 记成 succeeded("预备份 "),不是 skipped。 老判据要求 prebackup:skipped,于是真实有效的预备份也会被判红——但不能因此 取消备份校验(见下)。

判据(负例优先):

  • 认领行(library job <id> takeover claimed at=… kind=… scope=… target=… backup=… queue=… path=…) 只在 claim + attach 都成功之后写;claim=false 或 attach 失败时一行都不许有 (撤掉"先认领后写日志"的顺序 ⇒ 用例红);
  • 决策行(… takeover decision=fresh|pending_final|…)在动手之前写;after:database 之后判成 pending_final 就是上一轮的假完成(负例:把决策改成 pending_final ⇒ 判据红);
  • 判据脚本只认带本作业 id 且时间戳晚于杀点的行:只有汇总行、别的作业的认领行、 杀之前的旧日志,三种都必须红(scripts/takeover_log_contract.py --self-test 逐条证);
  • prebackup 允许 skipped 或 succeeded,但必须同时证明"复用同一份、没被重做覆盖": 预备份快照 id 不变 + 清单文件字节/mtime 不变 + 版本行 created_at 仍是首次登记时刻 (RecordLibraryVersion 对 production_prebackup 取 LEAST,生效版本仍写当前时刻)
    • 证据里有 backup_reused=true。取消这些校验等于允许"崩溃后重做备份",那会把半写状态 当成可回滚基线。

修法:cmd/worker/library_takeover_log.go 一份实现(认领行/决策行),两条认领路径 共用;scripts/takeover_log_contract.py 是唯一判据(含自检,进 make verify); 演练脚本 verify_takeover_semantics 改成上面那组断言。

20. 复用预备份时"重新造一份证据" ⇒ 已持久化的写集合锚点被整份抹掉

症状(2026-10-30T00:58Z 真实演练,job librestore-internal-1793321916382871758-retry): SIGKILL 137 后按策略重启,接管认领与决策行都对,database/resources/index/wiki/verify/finalize 全部 succeeded,预备份快照 id、manifest 的 hash/mtime、首次 created_at 都没变, backup_reused:true 也在;但 verify_effective 失败 —— 生效链上的 writeset_digest 从 fddd98cf70… 变成空。直接读生产库:

production_prebackup : {job, backup_bytes:0, backup_reused:true, backup_snapshot, index_mapping_sha256}
production_effective : {…, backup_bytes:0}

writeset_* 一个都不剩。同一作业第二次中断后再接管,loadWriteSetForJob 已经读不回 记录(预备份 … 没有登记写集合摘要)—— 恢复链在"再次故障"这一档彻底断掉。

根因:BackupPrepared 每次都新造一份证据 map(只放 backup_snapshot/backup_bytes/ job/index_mapping_sha256/backup_reused),而 RecordLibraryVersion 的 upsert 是 evidence = EXCLUDED.evidence(整份覆盖)。接管路径上:

  • ledgerBackupStore 只带 WriteSet(没有字节数)⇒ 新证据里 backup_bytes 写 0;
  • WriteSetPlanned 只在首轮预检时调用(backup.WriteSet == nil 才规划), 接管复用不重算 ⇒ 被抹掉的 writeset_* 没有任何一条路径会补回来。

判据(负例优先):

  • 真实 PG:BackupPrepared → WriteSetPlanned → loadWriteSetForJob → 接管 Run(复用)→ 再 loadWriteSetForJob 必须仍取回同一摘要;连做两轮中断+ 第三次完整恢复后,证据里 writeset_digest/writeset_rows/backup_bytes(真实值)/job/ backup_snapshot/backup_reused 逐项还在,created_at 等于首次登记时刻;
  • 把合并改回"新造一份 map"⇒ 该用例必须红(实测:第 1 次接管后写集合丢失);
  • 复用却没有任何已登记证据、字节数与已登记不一致、证据 job/backup_snapshot 与本次不符 ⇒ 一律失败(不许"看起来对就合",那会把别的作业的写集合带进来);
  • 同一作业第二次预检出不同 writeset_digest ⇒ 失败(台账不再描述"实际写了什么");
  • 复用拿不到真实字节 ⇒ 失败,不许把 0 写进证据(backup_bytes:0 与"空备份"不可区分)。

修法:prebackupEvidence(cmd/worker/library_writeset.go)一份实现,先核 job/backup_snapshot 身份再合并旧证据;接管路径用 prebackupBytesOf 从首次 登记的证据取回真实字节交给 ledgerBackupStore(Bytes<=0 直接拒绝); 生效版本证据统一由 effectiveEvidence 构造。预备份版本行的归属语义: 一条预备份版本行只属于首次登记它的那个作业,别的作业撞同一个快照 id 由 LibraryVersionByRef 报"属于作业 X"——那不是"没有记录",不许覆盖。