方向偏差成立,且是这轮评审不收敛的唯一根因。issue 要的是「一个 job 多次 docker build 时把 Dockerfile 归给正确的镜像」,五条范围在第 10 轮左右(09-08 17:48)就已交付、AC 守卫全绿;此后 35 轮、约 34 小时、+4,500 行,全部在把 provenance.py 变成一个手写的 bash 静态解释器(列表状态、帧栈、heredoc、case、trap、pipefail、协程、函数体重放)。
真实数据不支持这条路。0904 全批 3,260 条 job 脚本(去重后 339 种)里,裸 docker build 只有 2 条、多次 build 只有 1 条;解释器建模的全部 shell 形态(heredoc / case / trap / pipefail / eval / coproc / 函数定义 / ! / 子壳 / sh -c)出现次数为 0。用 PR 最终代码回放:job 选择与 main 在 1,582 条流水线上逐条相同,Dockerfile 归属只改变 1 条,正是那台 openclaw。一段 30 行的 tag 匹配就能改变这 1 条。
建议:不按现状合并;拆成三个小 PR 重做归属、保留其余成果。范围 2–5(Dockerfile 侧、runner 侧、审计侧,约 1,300 行)质量可用,可从 #443 整块摘取;范围 1(provenance,+5,500 行)重写为「文本级高召回登记 + tag 精确选择 + 镜像侧对账 + 显式降级留痕」,不模拟 shell 执行。同时给评审循环装三道闸:真实命中数、轮次上限、回归率。
provenance.py 行数,7.3×issue 是「归属选择」问题,PR 把它做成了「shell 执行语义完备性」问题。第 22 轮之后每轮新增的机制都在为下一轮制造评审对象;作者第 31 轮自己点名了这一点并请人拍板,第 32 轮又自行撤回继续。
用「模拟执行」回答「证据选择」,把不可判定的问题放进每个 archive 都要跑的热路径;现成的镜像侧证据(层文件清单)一次没用。测试 7,600 行钉的是形态不是性质。
Codex 233 条采纳 222 条(95%):没有任何「范围外」过滤器。约 101 条意见是上一轮修复引入的回归(作者自报)。整个循环里没有人、也没有真实数据参与。
在已建的 worktree-ir439-rework(main)上做三个小 PR;#443 转 draft 留作素材库。评审规则:每条意见附真实命中数,0 命中记遗留;连续 3 轮未解决数不降即停。
下面每个数字都是本次从 GitHub、PR 分支与湖仓(dev_ods,0904 批)现场取得的,取法见附录。先看体量与曲线,再看真实数据。
| 文件 | main | PR HEAD | 增幅 | 说明 |
|---|---|---|---|---|
| ontoos_extract/code_extract/provenance.py | 876 | 6,389 | +5,513 | 其中约 4,700 行是 shell 建模:_ListState _BlockState _Nesting _HeldBodies _LexState _replay_body _run_exit_handlers _heredoc_scan _case_pattern_matches _bash_glob… |
| …/dockerfile_parser.py | 487 | 1,187 | +700 | 同 dst 合并、source-dir、stage 继承链、ENV/ARG/WORKDIR 展开 |
| …/runner.py | 6,342 | 6,768 | +426 | 归属接线、同根去重、backend-node、解析失败降级留痕 |
| …/archive_health.py + status_audit.py | 940 | 1,058 | +118 | shared-source-root 跟随者契约(写时门 + 湖仓审计两侧) |
| tests/test_code_extract_provenance.py | 60 用例 | 316 用例 | +7,615 行 | 绝大多数钉 shell 形态;本机 HEAD 复跑五套 450 passed / 45 s |
| scripts/replay_dockerfile_attribution.py | — | 245 | 新增 | AC6 离线回放脚本;PR 正文标注「交执行机」,本次已替它跑完(见下) |
把 PR 最终代码(3abf8d05)与 main 的 provenance.py 同时装载,对 0904 批 code_pipeline_jobs 全部 3,260 条 job 脚本做三件事:数形态、比 job 选择、比 Dockerfile 归属。
| 形态(before_script + script 全文正则) | 出现 job 数 | 与 PR 的关系 |
|---|---|---|
helm_package(ci-java-v2 约定) | 1,120 | 约定车道,main 已支持 |
docker_build 包装函数调用 | 520 | 约定车道,main 已支持 |
&& / || | 44 | 第 11–13 轮 AND-list 状态机的对象 |
cd / pushd | 17 | 第 3–9 轮 cwd 跟踪的对象 |
| 反斜杠续行 / for、while / if / exit | 5 / 4 / 1 / 1 | main 已处理续行;其余各 1–4 条 |
裸 docker build | 2 | 整批只有两种脚本文本;其中 1 条两次 build(openclaw) |
heredoc、case、trap、pipefail、eval、coproc、函数定义、前导 !、set -e、子壳 ( )、sh -c、kaniko、buildah、sudo/timeout/flock 包装器 | 0 | 第 14–45 轮约 250 条线程的全部对象 |
| 对比项(main 代码 vs PR 代码,同一批输入) | 样本 | 相同 | 不同 | 不同的是谁 |
|---|---|---|---|---|
pick_job_for_image(选哪个 job) | 1,582 条流水线 | 1,582 | 0 | —(两侧均 0 抛异常;PR 与 main 同样有 529 条选不到 job) |
Dockerfile 归属(main 取 [0] vs PR pick_dockerfile_for_image) | 1,053 个有 job 的 archive | 1,052 | 1 | xagent-openclaw-runtime:openclaw/Dockerfile → Dockerfile |
PR 自带回放脚本 replay_dockerfile_attribution.py(AC6) | 1,053 | 1,052 | 1 | 同上;jobs_errored = 0,0.4 秒跑完 |
这就是 AC6 要的「归属变化清单」:issue 原文预期「只含一 job 多 build 的仓,本仓在列,其余 1,050 条不变」,实测 1 变 / 1,052 不变,与预期逐字一致。PR 正文把这条标成「⏳ 交执行机」,实际用 121 跳板五分钟就能取到。
结论:有偏差,从「归属判据」滑向了「bash 执行语义的完备性」,这是不收敛的唯一根因。
issue #439 的问题是一句话:一个 CI job 跑了两次 docker build,旧代码取 referenced_dockerfiles[0],把中间基础镜像的 Dockerfile 套给了发布镜像。答案也是一句话:逐条 build 登记 (dockerfile, tags),用目标镜像的 tag 挑;挑不中就取最后一条并告警。这在第 1 个提交 88bf2cdb(+220 行)里已经写对了,openclaw 那条脚本用它就能归对。
偏差是怎么发生的?看提交标题就能读出三段轨迹:
--target 传递、跟随者状态一致性。这些是真问题,修得也对。docker build 的 build context 是哪个目录」,开始跟踪 cd、子壳、AND-list 后台化、包装器(sudo / timeout / flock / bash -c)、kaniko / buildah / podman 车道。每一条在逻辑上都成立,但已经离开了 issue:本仓 3,260 条脚本里 cd 只有 17 条、包装器 0 条。! 取反、heredoc 引号感知扫描、case 模式匹配(含 ;;&、POSIX 字符类、nocasematch)、trap 信号谱、exit/exec/return 三层终止、pipefail 三值、协程隔离、派发扇出上限。这是在写一个 bash 解释器。作者第 31 轮的原话:「第 22 轮之后每一轮都在把 provenance.py 变成一个更完整的 bash 解释器……这条清单没有可见的尽头。」三个佐证说明这不是「多做了一点」而是方向性错误:
eval、外部命令退出码、运行时环境)。作者自己的差分自查在第 45 轮仍报告:1,200 条随机形态里 52 条丢 build、128 条假阳;第 44 轮刚把丢 build 从 90 降到 52,第 45 轮又新增 3 条 P1。这不是「再两轮就收口」的形态,是一个开放集合。偏差的机制:评审判据只有一条——「会不会产出本票要消灭的那类错误(丢 build / 归属错)」——没有第二条「在真实脚本中的出现概率与代价」。在这一条判据下,Codex 每轮构造 5–13 个反例都「成立」,作者按「守卫两步验」逐条修,修复又引入下一轮的反例(作者自报 359 条里约 101 条是回归)。两个 AI 都以「构造反例」为方法,而反例是无穷的。作者在第 31 轮正确地识别了这一点并给出 A/B/C 三个选项请人拍板,没有等到回复就在第 32 轮自行撤回,又跑了 13 轮。
结论:范围 2–5 的实现合理、贴近既有结构,可以整块保留;范围 1(provenance)的架构不合理,应重写而不是继续修。
map_copy_to_components 的 _add 折叠 + merged_copies / merged_source_roots 信号):落实了 docstring 早已写明的契约,落盘信号可查,判据清楚。kind='source-dir',只认最后 stage 及其 FROM 链,ships_build_artifact 豁免制品镜像):把影响面收在「本来就没有组件」的镜像上,两道闸各有守卫。comp_abs 为键只跑一次 FE;shared-source-root:<leader> 在写时门与湖仓审计两侧同口径校验领跑者存在、同 lane、同状态、双方 requested):这是本 PR 里最像「架构」的一段,两侧共用 archive_health 的常量与判据函数,符合仓里「写库收口」的传统。backend-node 值域与浏览器侧信号三源(框架词表 / browserslist / web 根 COPY),通用打包器单独出现不算前端:判据方向正确,COLUMN_COMMENTS 同步。_safe_parse_job_script / _safe_pick_job_for_image 写 job-script-parse-failed / job-selection-failed 诊断,archive_status='partial'):符合「已知为空 ≠ 不知道」的仓内铁律。需要注意的膨胀点:Dockerfile 解析器里加入了 ENV / ARG / WORKDIR 展开与「ARG 默认值决定 FROM 祖先」的推断。Dockerfile 语义同样是开放的(--build-arg 覆盖、多阶段变量作用域),这一段应写进 ADR 并明确「只做字面量可解的那一格,其余保守」,否则会成为下一个 provenance。
parse_job_script 对每个 archive 的每个 job 都跑。第 41 轮发现过派发扇出是阶乘级(8 个 helper 各以 $RUN_HOOK 收尾 = 13,700 条 build,15 个永不返回),靠加深度上限止血;60,000 条形态 fuzz 才把「不抛异常」钉住。一个 6,000 行的解释器本身就是新的稳定性面,而它保护的是本批 0 次出现的形态。builds)与粗车道(_script_invokes_build 的 own-script 闸、_executed_script_text)共用 _ListState / _Nesting,但消费点分散——作者的第 4 条、第 25 条教训(「同一条口径要一次找齐它的全部消费者 / 出口」)在本 PR 里复发了至少七次(跳过保留字三轮各补一处、target_known 有产出无消费、pipe_failed 三个出口只改一个、adopt_options 缺 ( 帧那一半…)。这不是纪律问题,是结构问题:状态被拆在参数列表与闭包里跨行携带(_parse_docker_builds 有 8 个可变参数),没有一个执行上下文对象。( ) vs { } 一致)是第 43 轮才立的。crane blob 取镜像层文件清单(_layer_file_listing),并按候选 Dockerfile 的 COPY dst 挑层(pick_layers_for_copy_targets)。这意味着「候选 Dockerfile 最后 stage 的 COPY dst 是否真的出现在镜像层里」是一个已经付过成本的、与 shell 无关的对账信号:openclaw 那台镜像,openclaw/Dockerfile 的 dst 命中 app/node_modules,根 Dockerfile 的 dst 命中 opt/xagent,真相在层清单里一目了然。PR 全程没有触碰这条证据。shell_evidence.py)加独立 ADR。结论:分层看。第 1–10 轮多数实质;第 11–20 轮逻辑成立、实质稀薄;第 21–45 轮形式成立、实质为零,并且相当比例是评审在给自己制造工作。
| 轮次区间 | 线程 | Codex | 自审 | 典型意见 | 实质性判断 |
|---|---|---|---|---|---|
| R1–R10(09-08 07:08–12:50) | 88 | 55 | 33 | 完整 tag 先于 bare name;最后 stage 继承的 COPY;制品抑制只算进镜像的 stage;before_script 的 cd;跟随者与领跑者终态一致;审计豁免须验证领跑者;「同一趟只有一条候选」时闸不应生效 | 实质 落在五条范围上,对应真实形态(ci-java-v2、多 stage Java Dockerfile、状态审计不变量) |
| R11–R20(09-08 12:57–21:30) | 88 | 65 | 23 | 包装器带值 flag;kaniko 相对 context;子壳栈跨段;AND-list 后台化;podman / nerdctl 车道;WORKDIR 进 dst;ENV 代入 COPY | 稀薄 每条单看都对,但本批命中:cd 17、&& 44、其余 0;且 9 条是上一轮引入的回归 |
| R21–R31(09-08 21:45–09-09 19:00) | 135 | 89 | 46 | heredoc 引号感知;case 臂选择与 ;;&;函数体寄存/重放/前向调用;trap 信号谱与 EXIT handler;eval 载荷;local 生命周期;unset -f;字面量循环条件 | 零实质 本批出现 0 次;是「bash 语义 vs 解析器」的差分,无穷集合 |
| R32–R45(09-09 19:26–09-10 09:30) | 57 | 24 | 33 | declare -f 查询形态;shopt -q;break N;exec 三层终止;pipefail × 段状态 × 出口矩阵;! 与管道/花括号边界;派发阶乘级挂死 | 零实质 同上;唯一有生产意义的是「阶乘级挂死」——而它是第 25 轮机制自己造出来的 |
几个结构性的观察,比逐条评价更重要:
code_pipeline_jobs。第 31 轮的选项 B 正是要一份「真实脚本样本」当边界判据——那份样本在湖仓里,五分钟可得。set -o pipefail 逃出 ( ) / 子壳内被整条丢弃 / ! 被 finish 二次折叠冲掉 / deferred 未进快照 / ! { … } 落进内层列表)全部是第四层意见,本批 0 次出现,不应阻塞任何决策。它们的存在本身说明这条轴不会自己停下来。公平地说:Codex 与自审在第 1–10 轮抓到的问题是真的,其中「目标镜像自己那条 build 认不出 Dockerfile 时被另一条 build 顶包」「归属退回后仍套用原 build 的 --target / context」「跟随者攥着降级前的 succeeded」这类意见,如果漏掉,会在下一批产出静默错误。问题不在评审者的判断力,在于没有人给它一条「相对真实数据的代价」的判据。这也是仓里 PR #392、#403、#435 三次长循环共同的教训(「修复的代价要与它防的风险对称」),这次以更极端的形态复发。
推荐路线:停止在 #443 上继续轮次;把它转为 draft 保留作素材;在已建的 worktree-ir439-rework(目前停在 main 0404d477)上按三个小 PR 重做。这对应作者第 31 轮的选项 C,但拆分口径比他估的简单:#443 里 dockerfile_parser / runner / archive_health / status_audit / schema 的改动彼此独立、与 provenance 只通过 DockerfileAttribution 一个数据类接口耦合,可以整块摘取;只有 provenance.py 需要重写。
ParsedBuild(dockerfile, tags, target, context):逐逻辑行 shlex 分词、按 && || ; | 切组、登记每条 docker/buildx/kaniko/buildah build;不做任何执行语义(函数体、条件、子壳一律登记,方向 = 高召回)。pick_dockerfile_for_image 三趟:完整 ref → bare name(字面 tag 后缀冲突排除)→ ${CI_PROJECT_NAME} 模板;无命中取最后一条有 Dockerfile 的 build,写 WARNING + health 诊断 dockerfile-attribution-fallback。detection_signals.attribution_corroborated_by=layer-listing。这是确定性证据,成本已付。ontoos-code-ac-verify 作为下批验收的固定项。dockerfile_parser.pymerged_copies / merged_source_roots。ships_build_artifact / ships_jar_artifact 两闸。git diff origin/main...origin/pr-443 -- ontoos_extract/code_extract/dockerfile_parser.py tests/test_code_extract_dockerfile_parser.py 直接 apply,再删掉依赖 provenance 新字段的两处参数默认值即可编译。fe_leader_by_root + shared-source-root:<leader> 跟随者契约,写时门与湖仓审计两侧同判据。archive_kind='backend-node' + 浏览器侧三源信号 + COLUMN_COMMENTS;master-plan DDL 注释同步。_safe_parse_job_script / _safe_pick_job_for_image 降级留痕(partial + 诊断码)。DockerfileAttribution 接口,先合 A 再合 C;B 与 A 无依赖可并行。.gitlab-ci.yml 片段;命中为 0 的意见记入「发现与遗留」,不修、不 resolve、不阻塞。这就是作者第 31 轮要的边界判据,本报告附录给出了一条 SQL 就能算的方法。如果时间压力要求「就用 #443」:可行但不推荐的最小方案是把 provenance.py 回退到第 10 轮末的 a1f3ab56(1,853 行,五条范围已交付),保留其余文件的 HEAD 版本,再补镜像侧对账。代价是那一版已经带着 cwd / 子壳跟踪的雏形,且第 1–10 轮之间的 runner 接线改动需要人工对齐——工作量与重写 PR-A 相当,但留下的是一段没人愿意再碰的代码。
gh issue view 439;gh pr view 443(正文 46 KB、77 提交、501 条 review 事件、105 条顶层评论);GraphQL reviewThreads 分页 4 次取全 368 条线程。git fetch origin refs/pull/443/head 后与 origin/main 逐提交比对行数;在独立 worktree 上用仓内 venv 跑 test_code_extract_provenance.py 等五套:450 passed / 45 s。ssh -L 15432 连 dev_ods,batch_id LIKE '20260904_112726_%'(batch_id 按 archive 带后缀,须用前缀);code_pipeline_jobs 3,260 行、code_archives 1,716 行。# 1) 真实脚本形态分布(任何一条意见都先跑这个)
SELECT count(*) FILTER (WHERE script ~* 'pipefail') AS pipefail,
count(*) FILTER (WHERE script ~* '(^|\n)\s*case\b') AS case_stmt,
count(*) FILTER (WHERE script ~ '(?<!<)<<(?!<)') AS heredoc,
count(*) FILTER (WHERE script ~* '\bdocker\s+(buildx\s+)?build\b') AS docker_build
FROM dev_ods.code_pipeline_jobs WHERE batch_id LIKE '20260904_112726_%';
# 2) PR 自带回放(AC6),本次实测 1 变 / 1,052 不变 / 0 错
python scripts/replay_dockerfile_attribution.py --config <pg-config.yaml> --batch 20260904_112726_% # 注意:脚本按 = 过滤,需改 LIKE 或逐 archive 传
# 3) main 与 PR 的 job 选择对拍:两份 provenance.py 同时装载,对 1,582 条流水线跑 pick_job_for_image
# 结果:1,582 相同 / 0 不同 / 两侧 0 异常(脚本见本次会话 scratchpad/lake_analysis2.py)
docker build + 复杂 shell,需要重新评估——这正是建议用真实命中数当闸的原因。3abf8d05,main 0404d477,湖仓 dev_ods 0904 批 · 图表调色板经 dataviz 校验(明暗两模式均通过)