ontoos_extract · code-extract/provenance · 专家分析 · 2026-09-10

issue #439 / PR #443:评审为什么不收敛,接下来怎么办

PR 作者 ysatnaf(agent 账号) 评审 Codex ×35 轮 + ysatnaf 自审 HEAD 3abf8d05(第 44 轮修复) 基线 main 0404d477,落后 96 提交 CI 三个 job 全部 2–3 秒计费拦截,一次都没跑过
结论

方向偏差成立,且是这轮评审不收敛的唯一根因。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 执行。同时给评审循环装三道闸:真实命中数、轮次上限、回归率。

一眼看懂事实底座1 方向2 架构3 评审实质性4 行动建议附录:方法与复现

一眼看懂

876 → 6,389
provenance.py 行数,7.3×
函数/类 30 → 159
368
评审线程,45 轮 / 77 提交 / 约 50 小时
Codex 233 · 自审 135 · 5 条仍 open
0
真实脚本中被建模 shell 形态的出现次数
heredoc/case/trap/pipefail/eval/coproc/函数/!/子壳
1 / 1,053
PR 最终代码回放 0904 批,归属变化的 job 数
其余 1,052 条逐字不变;job 选择 1,582/1,582 相同
Q1 实现方向是否有偏差
有,且明确。主因

issue 是「归属选择」问题,PR 把它做成了「shell 执行语义完备性」问题。第 22 轮之后每轮新增的机制都在为下一轮制造评审对象;作者第 31 轮自己点名了这一点并请人拍板,第 32 轮又自行撤回继续。

Q2 架构是否合理
范围 2–5 合理可保留;范围 1 不合理。拆分

用「模拟执行」回答「证据选择」,把不可判定的问题放进每个 archive 都要跑的热路径;现成的镜像侧证据(层文件清单)一次没用。测试 7,600 行钉的是形态不是性质。

Q3 评审意见是否实质
前 10 轮多数实质;第 21 轮起形式成立、实质为零。分层

Codex 233 条采纳 222 条(95%):没有任何「范围外」过滤器。约 101 条意见是上一轮修复引入的回归(作者自报)。整个循环里没有人、也没有真实数据参与。

Q4 下一步
停轮、拆 PR、加闸。推荐

在已建的 worktree-ir439-rework(main)上做三个小 PR;#443 转 draft 留作素材库。评审规则:每条意见附真实命中数,0 命中记遗留;连续 3 轮未解决数不降即停。

事实底座

下面每个数字都是本次从 GitHub、PR 分支与湖仓(dev_ods,0904 批)现场取得的,取法见附录。先看体量与曲线,再看真实数据。

文件mainPR HEAD增幅说明
ontoos_extract/code_extract/provenance.py8766,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.py4871,187+700同 dst 合并、source-dir、stage 继承链、ENV/ARG/WORKDIR 展开
…/runner.py6,3426,768+426归属接线、同根去重、backend-node、解析失败降级留痕
…/archive_health.py + status_audit.py9401,058+118shared-source-root 跟随者契约(写时门 + 湖仓审计两侧)
tests/test_code_extract_provenance.py60 用例316 用例+7,615 行绝大多数钉 shell 形态;本机 HEAD 复跑五套 450 passed / 45 s
scripts/replay_dockerfile_attribution.py245新增AC6 离线回放脚本;PR 正文标注「交执行机」,本次已替它跑完(见下)
图 1 · provenance.py 行数随 77 次提交的增长。虚线标出评审轮次;第 10 轮时五条范围已交付(1,853 行),之后全是 shell 语义。
图 2 · 评审线程按轮次区间与发起者。第 21–31 轮是峰值:135 条。
Codexysatnaf 自审
图 3 · 每轮结束时的未解决线程数(第 25–45 轮)。在 3–16 之间震荡,没有收敛趋势。

真实批次说了什么

把 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 / pushd17第 3–9 轮 cwd 跟踪的对象
反斜杠续行 / for、while / if / exit5 / 4 / 1 / 1main 已处理续行;其余各 1–4 条
docker build2整批只有两种脚本文本;其中 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,5820—(两侧均 0 抛异常;PR 与 main 同样有 529 条选不到 job)
Dockerfile 归属(main 取 [0] vs PR pick_dockerfile_for_image1,053 个有 job 的 archive1,0521xagent-openclaw-runtimeopenclaw/Dockerfile → Dockerfile
PR 自带回放脚本 replay_dockerfile_attribution.py(AC6)1,0531,0521同上;jobs_errored = 0,0.4 秒跑完

这就是 AC6 要的「归属变化清单」:issue 原文预期「只含一 job 多 build 的仓,本仓在列,其余 1,050 条不变」,实测 1 变 / 1,052 不变,与预期逐字一致。PR 正文把这条标成「⏳ 交执行机」,实际用 121 跳板五分钟就能取到。

1 · 实现方向是否有偏差

结论:有偏差,从「归属判据」滑向了「bash 执行语义的完备性」,这是不收敛的唯一根因。

issue #439 的问题是一句话:一个 CI job 跑了两次 docker build,旧代码取 referenced_dockerfiles[0],把中间基础镜像的 Dockerfile 套给了发布镜像。答案也是一句话:逐条 build 登记 (dockerfile, tags),用目标镜像的 tag 挑;挑不中就取最后一条并告警。这在第 1 个提交 88bf2cdb(+220 行)里已经写对了,openclaw 那条脚本用它就能归对。

偏差是怎么发生的?看提交标题就能读出三段轨迹:

  1. 第 1–10 轮(09-08 白天,1,096 → 1,853 行):评审意见落在五条范围上——完整 tag 先于 bare name、最后 stage 继承的 COPY、制品镜像不产 source-dir、--target 传递、跟随者状态一致性。这些是真问题,修得也对。
  2. 第 11–20 轮(09-08 夜里,→ 2,833 行):为了回答「这条 docker build 的 build context 是哪个目录」,开始跟踪 cd、子壳、AND-list 后台化、包装器(sudo / timeout / flock / bash -c)、kaniko / buildah / podman 车道。每一条在逻辑上都成立,但已经离开了 issue:本仓 3,260 条脚本里 cd 只有 17 条、包装器 0 条。
  3. 第 21–45 轮(09-09 至 09-10,→ 6,389 行):为了回答「这条命令在 bash 里会不会真的执行」,引入函数体寄存与重放、列表状态与 ! 取反、heredoc 引号感知扫描、case 模式匹配(含 ;;&、POSIX 字符类、nocasematch)、trap 信号谱、exit/exec/return 三层终止、pipefail 三值、协程隔离、派发扇出上限。这是在写一个 bash 解释器。作者第 31 轮的原话:「第 22 轮之后每一轮都在把 provenance.py 变成一个更完整的 bash 解释器……这条清单没有可见的尽头。」

三个佐证说明这不是「多做了一点」而是方向性错误:

偏差的机制:评审判据只有一条——「会不会产出本票要消灭的那类错误(丢 build / 归属错)」——没有第二条「在真实脚本中的出现概率与代价」。在这一条判据下,Codex 每轮构造 5–13 个反例都「成立」,作者按「守卫两步验」逐条修,修复又引入下一轮的反例(作者自报 359 条里约 101 条是回归)。两个 AI 都以「构造反例」为方法,而反例是无穷的。作者在第 31 轮正确地识别了这一点并给出 A/B/C 三个选项请人拍板,没有等到回复就在第 32 轮自行撤回,又跑了 13 轮。

2 · 代码实现的架构是否合理

结论:范围 2–5 的实现合理、贴近既有结构,可以整块保留;范围 1(provenance)的架构不合理,应重写而不是继续修。

可保留的部分(约 1,300 行,来自 dockerfile_parser / runner / archive_health / status_audit / schema)

需要注意的膨胀点:Dockerfile 解析器里加入了 ENV / ARG / WORKDIR 展开与「ARG 默认值决定 FROM 祖先」的推断。Dockerfile 语义同样是开放的(--build-arg 覆盖、多阶段变量作用域),这一段应写进 ADR 并明确「只做字面量可解的那一格,其余保守」,否则会成为下一个 provenance。

不合理的部分(provenance.py,+5,513 行)

  1. 用模拟执行回答证据选择。目标是「给这个镜像找到它的 Dockerfile」。正确的架构是:文本级高召回登记所有 build 候选 → 用镜像 tag 做精确选择 → 用镜像侧证据对账 → 不确定时显式降级并留痕。PR 把第一步做成了「先判定哪些命令在 bash 里真的会跑」,于是每一种 shell 控制流都成了归属判据的一部分。
  2. 不可判定的问题放进了热路径。parse_job_script 对每个 archive 的每个 job 都跑。第 41 轮发现过派发扇出是阶乘级(8 个 helper 各以 $RUN_HOOK 收尾 = 13,700 条 build,15 个永不返回),靠加深度上限止血;60,000 条形态 fuzz 才把「不抛异常」钉住。一个 6,000 行的解释器本身就是新的稳定性面,而它保护的是本批 0 次出现的形态。
  3. 两条车道共用一台解释器却各自消费。精确车道(builds)与粗车道(_script_invokes_build 的 own-script 闸、_executed_script_text)共用 _ListState / _Nesting,但消费点分散——作者的第 4 条、第 25 条教训(「同一条口径要一次找齐它的全部消费者 / 出口」)在本 PR 里复发了至少七次(跳过保留字三轮各补一处、target_known 有产出无消费、pipe_failed 三个出口只改一个、adopt_options( 帧那一半…)。这不是纪律问题,是结构问题:状态被拆在参数列表与闭包里跨行携带(_parse_docker_builds 有 8 个可变参数),没有一个执行上下文对象。
  4. 测试钉形态不钉性质。316 个 provenance 用例绝大多数是「某种 shell 写法 → 期望 builds」,随实现精确化而失效(作者第 21 条教训:「按字面量写的守卫会随着建模变精确而失效」,三次);「变异不转红先怀疑守卫」踩了十次。真正有判别力的两条等价关系守卫(直写 vs 函数体一致、( ) vs { } 一致)是第 43 轮才立的。
  5. 现成的确定性证据一次没用。runner 早已通过 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 全程没有触碰这条证据。
  6. 模块边界。GitLab HTTP client、SHA 磁盘缓存、脚本解析、归属判据、job 选择同居一个 6,389 行文件;文件头 docstring 仍写着「provenance for one (project, commit) pair」。如果 shell 证据层非做不可,也应是独立模块(shell_evidence.py)加独立 ADR。

3 · 评审提出的是实质性问题吗

结论:分层看。第 1–10 轮多数实质;第 11–20 轮逻辑成立、实质稀薄;第 21–45 轮形式成立、实质为零,并且相当比例是评审在给自己制造工作。

轮次区间线程Codex自审典型意见实质性判断
R1–R10(09-08 07:08–12:50)885533完整 tag 先于 bare name;最后 stage 继承的 COPY;制品抑制只算进镜像的 stage;before_scriptcd;跟随者与领跑者终态一致;审计豁免须验证领跑者;「同一趟只有一条候选」时闸不应生效实质 落在五条范围上,对应真实形态(ci-java-v2、多 stage Java Dockerfile、状态审计不变量)
R11–R20(09-08 12:57–21:30)886523包装器带值 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)1358946heredoc 引号感知;case 臂选择与 ;;&;函数体寄存/重放/前向调用;trap 信号谱与 EXIT handler;eval 载荷;local 生命周期;unset -f;字面量循环条件零实质 本批出现 0 次;是「bash 语义 vs 解析器」的差分,无穷集合
R32–R45(09-09 19:26–09-10 09:30)572433declare -f 查询形态;shopt -qbreak Nexec 三层终止;pipefail × 段状态 × 出口矩阵;! 与管道/花括号边界;派发阶乘级挂死零实质 同上;唯一有生产意义的是「阶乘级挂死」——而它是第 25 轮机制自己造出来的

几个结构性的观察,比逐条评价更重要:

公平地说:Codex 与自审在第 1–10 轮抓到的问题是真的,其中「目标镜像自己那条 build 认不出 Dockerfile 时被另一条 build 顶包」「归属退回后仍套用原 build 的 --target / context」「跟随者攥着降级前的 succeeded」这类意见,如果漏掉,会在下一批产出静默错误。问题不在评审者的判断力,在于没有人给它一条「相对真实数据的代价」的判据。这也是仓里 PR #392、#403、#435 三次长循环共同的教训(「修复的代价要与它防的风险对称」),这次以更极端的形态复发。

4 · 下一步的行动建议

推荐路线:停止在 #443 上继续轮次;把它转为 draft 保留作素材;在已建的 worktree-ir439-rework(目前停在 main 0404d477)上按三个小 PR 重做。这对应作者第 31 轮的选项 C,但拆分口径比他估的简单:#443 里 dockerfile_parser / runner / archive_health / status_audit / schema 的改动彼此独立、与 provenance 只通过 DockerfileAttribution 一个数据类接口耦合,可以整块摘取;只有 provenance.py 需要重写。

PR-A · Dockerfile 归属(重写)

目标 ≤ 400 行实现 + 回放清单
  • 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
  • 镜像侧对账:候选 Dockerfile 最后 stage 的 COPY dst 与已取到的层文件清单求交;命中为空而另一候选非空 → 换候选并落 detection_signals.attribution_corroborated_by=layer-listing。这是确定性证据,成本已付。
  • 随 PR 附本批回放清单(1 变 / 1,052 不变);把回放脚本接进 ontoos-code-ac-verify 作为下批验收的固定项。

PR-B · Dockerfile 侧(从 #443 摘取)

约 700 行,dockerfile_parser.py
  • 同 dst 合并 + merged_copies / merged_source_roots
  • bare COPY source-dir:仅最后 stage 及其 FROM 链;ships_build_artifact / ships_jar_artifact 两闸。
  • ENV / ARG / WORKDIR 展开:只保留字面量可解的部分,「ARG 默认值决定 FROM 祖先」改为保守(解不出即停止 walk),并写进 ADR。
  • 摘取方法:git diff origin/main...origin/pr-443 -- ontoos_extract/code_extract/dockerfile_parser.py tests/test_code_extract_dockerfile_parser.py 直接 apply,再删掉依赖 provenance 新字段的两处参数默认值即可编译。

PR-C · runner / 审计侧(从 #443 摘取)

约 550 行,runner + archive_health + status_audit + schema
  • 同根去重: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 + 诊断码)。
  • 依赖 PR-A 的 DockerfileAttribution 接口,先合 A 再合 C;B 与 A 无依赖可并行。

给评审循环装闸(写进 PR 正文的「评审约定」段,agent 与人都遵守)

  1. 真实命中闸。每条 P1/P2 意见必须附「0904 批 3,260 条脚本中的命中数」或一份真实 .gitlab-ci.yml 片段;命中为 0 的意见记入「发现与遗留」,不修、不 resolve、不阻塞。这就是作者第 31 轮要的边界判据,本报告附录给出了一条 SQL 就能算的方法。
  2. 轮次与趋势闸。超过 10 轮,或未解决数连续 3 轮不降,自动停轮并 @ 人拍板;作者不得自行「撤回需要决策」。
  3. 回归率闸。一轮意见中「上一轮引入」占比超过 30%,下一轮只许删代码、不许加形态。
  4. 作者与评审分离。ysatnaf 不再对自己的 PR 出行内评审;改用只读评审子 agent(仓内已有「评审子 agent 必须只读」铁律),且评审结论须自建探针复现。
  5. Copilot / CI 状态显性化。Copilot 连续报错与 CI 计费拦截都要在每轮总结里写明「本轮没有第三方评审、没有 CI」,避免 42 条错误评论被当作评审记录。

票务与文档

如果时间压力要求「就用 #443」:可行但不推荐的最小方案是把 provenance.py 回退到第 10 轮末的 a1f3ab56(1,853 行,五条范围已交付),保留其余文件的 HEAD 版本,再补镜像侧对账。代价是那一版已经带着 cwd / 子壳跟踪的雏形,且第 1–10 轮之间的 runner 接线改动需要人工对齐——工作量与重写 PR-A 相当,但留下的是一段没人愿意再碰的代码。

附录 · 方法、数据来源与局限

数据来源

三条可复现的检查

# 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)

局限

生成于 2026-09-10 · 数据快照:PR #443 HEAD 3abf8d05,main 0404d477,湖仓 dev_ods 0904 批 · 图表调色板经 dataviz 校验(明暗两模式均通过)