diff --git a/.claude/CLAUDE.handoff-refine.20260906-0627.md.bak b/.claude/CLAUDE.handoff-refine.20260906-0627.md.bak new file mode 100644 index 000000000..581f1b750 --- /dev/null +++ b/.claude/CLAUDE.handoff-refine.20260906-0627.md.bak @@ -0,0 +1,1755 @@ +# 用户的规矩 —— 读任何别的东西之前先读这里 + +> 用户 2026-09-05:*「拜托你把我说的话都沉淀到 CLAUDE.md 的最前面,每次都忘。」* +> 下面每一条都是用户亲口说过、而且**我至少忘过一次**的。忘记的代价写在每条后面, +> 那不是修辞,是当天的账。 + +## 集群 + +1. **我们持有的所有机器上,凡是不是我们和系统的 GPU 占用,一律杀掉,然后并行安排 + 工作。** 用户 2026-09-05 放宽:**不分 QoS**,`amd-primus-q` 同样适用。 + (最初只说 burst-qos,同日放宽到全部持有的机器。) + 我方 = 带 `infera_e2e_run` 标签;系统 = 集群监控; + 其余全杀,**不论它跑了多久、不论谁还持有那台机器的配额**。 + **只杀 GPU 占用**——不占卡的 CPU 容器不动。 + 用 `docker stop -t 10`,**永远不要 `rm -f`**。 + **这条覆盖「你没创建的容器不归你删」和「两个持有者时归属不可判定」。** + + **前提(m5,2026-09-05 20:0x,量出了反事实):这条规矩假设占用者是静态的。** + `ajafari` 的 `t551` 是一次**多节点扫描,每个上下文长度起一个容器**。 + m5 停了 `t551-w3-10M`——**而那次扫描在 19:47 自己清空了,19:57 又回来。 + 那一停赔掉了别人一个扫描点,什么也没买到。** + > **对着一个会重生的作业,停它既不能腾出节点,也不会让共存租户完好—— + > 那不是执行规矩,那是在打一场对抗。** + **判据:如果占用者会重生,停止不是执行——该上报。** + *今晚我据此在 093 和 237 各裁定过一次「不去争」;m5 给的是它的一般形式, + 而且带着反事实的测量。* + *代价:我拿后两条当挡箭牌拖了四轮,217 在别人 34% 的作业下空转数小时。* + +2. **不允许申请机器,只允许查询和使用。** 不发 `sbatch` / `salloc`。 + 机器不够就说不够,不要自己解决。 + +3. **删除铁律:路径不含 `yihou` 或 `/tmp` 子串的东西,一律不删。** + 宿主机上如此,docker 挂进宿主机的目录里同样如此。 + +4. **算力要算「还剩多久」,不是「已用多久」。** 作业上限 8 小时,`squeue` 的 `%M` + 是已用。发一条线之前先算剩余时间够不够这条线跑完。 + *代价:昨天四个 hold 死在 8 小时整点的半途。* + +## 怎么用算力 + +5. **所有任务一律 TP4,不要占满八卡。** 一台 8 卡机同时跑两条线(0–3 / 4–7)。 + + **全真链必须取 0–3,不能取「哪半空就哪半」**(m2,2026-09-05,由 `GPUS=` 那条推出来的, + 我没推出来):`mix_worker.sh:26` 的 `GPUS` 有默认、无人设置,**所以 m5 的臂永远吃 + `0..TP-1`**。**把链跑在 4–7 上,会让阶段 5 落在和阶段 1–4 不同的卡上。** + + **例外(2026-09-05 16:12 实测,m3 发现、我读代码确认):任一半跑真实 m5 时, + 这个分法不成立。** `mix_up.sh:81` 调 `reset_gpus.sh`,后者 `:37` 遍历 + **整个节点**的 KFD 进程、`:48` 对 comm 匹配引擎名的一律 `kill -9`; + **只保护 `slurmstepd`,不保护另一条线的引擎。`--var gpu_devices` 不约束它。** + 还有第二重:`kv-events:5557` / `kv-snapshot:8801` 在 `mix_up.sh:70` 硬编码, + **两个 m5 阶段无论传什么端口都不能共存于一台节点**;而 `GPUS=` 在整个 + `assets/` 只出现一次(`mix_worker.sh:26`,有默认、无人设置),**所以 m5 的臂 + 永远吃 `0..TP-1`**。 + → **凡是 `mock_stages` 里不含 `m5` 的线,那台节点必须独占。** + *代价:我按这条规矩把 217 劈成两半,而 m1 的线会在约 16:44 杀掉 m3 的引擎—— + 那正是今天等了一整天才拿到的匹配 digest 节点上的实验。* + +6. **跨节点先试。** 失败了分析失败原因,不行再回退到同一节点。不要预先把它当路障。 + +## 本轮的目标与顺序 + +7. **本轮不关注端到端是否有真实性能提升,跑通即可。** + +8. **五个阶段,按这个顺序:** + ① 造一份新的 handoff + 重构一遍所有 validator + ② 单独并行跑每个 mock,清掉参差 + ③ 串通 mock + ④ 单独跑每个真实 + ⑤ 串通整个真实 + 两条线之间不断调试,按需修改 handoff(定义和造出来的实例)与 validator。 + +9. **e2e 调通阶段可以先把 task 从 program 改成 ai**,让 agent 吸收 handoff 里的 + 细微差异,避免不必要的失败——**前提是把 markdown 写明白**。 + (`agent/runner.py:801`:环境变量无法指示 agent,只有 brief 能。) + *代价:2026-09-05 我为了把 mock 回路压到 4 分钟,把五个阶段全部强制成 + `m*_agent=runner`(program),**正好和这条相反**,而且没说。* + +10. **造出来的 handoff,在评估影响不大时可以手工微调**,不必为此重跑真实负载。 + 用户 2026-09-05 追加:*「太久的错的语料不是我喂给你的,发现了顺手删掉就可以。」* + **但「微调」不等于「编造」**:语料 workset 连 `workset.yaml` 都没有,而 evidence + 是它里面的字段、validator 会交叉核对 measured spread——补它就是造测量数字。 + **诚实的做法是拿今天真机产出的同类产物去嫁接**,不是手写。 + *代价:我两次把语料缺口当成不可动的边界,直到用户第三次点破。* + +10a. **单独调试某个阶段时,不要求它产出真实的优化/提升。** 用户 2026-09-05: + *「m4 单独调试时不要求跑真实优化……怎么舍弃优化提升尽快拿到结果。」* + **可达性是目标,数字不是。** 降级产物必须自己声明降级。 + +10b. **下游阶段不许等上游跑完。** 用户 2026-09-05:*「m5 根本不需要等 m4 出来 + 再说。」* 把上游算子加一两行无关代码就是一份合法的**反向优化**产物—— + 空改动测出来的就是 baseline,所以 `speedup: 1.0` 是**正确值而不是猜测**。 + **m1/m2/m3 都真实跑通之后,下游没有任何「外界信息不足」的借口。** + 实例:`/home/yihou/make_reverse_kernel_opt.py`。 + +## Validator:先全关跑通,再二分打开 + +> 用户 2026-09-05,原话:*「每个节点真连调试的首个阶段,你可以把 validator 全部 +> disable 掉。着重跑通,然后拿着一份结果,逐个修 validator,这时只需要跑一遍 e2e +> 就能拿到更多的信息。validator 打开的过程中,也可以分阶段二分的打开,比如先打开 +> 稳定能 pass 的,再逐渐收敛到难 debug 的。过于难调的,单独记录文件上报给我就行, +> 真实跑通前不要把太多精力花在 validator 上。」* + +10c. **每个节点第一次真连,validator 全关。** 机制是 + `assets/lib/make_debug_package.py --out /e2e-flow-noval`,生成一棵副本。 + - `validators: []` **非法**——框架原话 *"A kind with no validator cannot be + admitted"*,所以注入 `check_nothing`(全 true、一个不读、往 stderr 打 + `validation is DISABLED for this run`、`strength: weak`)。 + - **必须生成在仓库内**:`env_mgr.workspace.cut` 要求包在带 + `extensions.preciousObjects` 的 git 仓库里。`e2e-flow-noval*/` 已 gitignore。 + - 回收用 `--keep a,b`,并且**保留 `check_nothing` 在每个 kind 上**,这样部分 + 恢复的树仍然自报降级。 + - **`check_deploy_serves` 不能进这个回路**——它做真实 bring-up + 180 秒压测, + 登录节点上会挂死。先砍它,其余 20 个一起开。 + +10d. **一个 `-noval` 的绿只证明「走通」,不证明任何正确性。** 而且它**分不清 + 「走通」和「越过了若干次静默失败」**——所有 verdict 都是 `check_nothing`。 + 唯一诚实的句子是「链子在关闭验证的情况下走完了五个阶段」。 + + **那么在 `check_nothing` 之下,什么还算证据?**(readme-cn 2026-09-05 的判据, + 今天最有用的一条,而且可计算。) + + > **既不是 verdict,也不是文件数,而是「生产者算出来的、其值依赖于它必须 + > 读过的输入」的内容。一个只有真的读了 A、读了 B、比较了两者才能得到的数, + > 静默失败伪造不出来。一个被拷贝进来的文件什么都不证明。** + + 实测:那次 17/17 的运行 495 个文件里 **165 个与语料逐字节相同(33%)**。 + 但**分 kind 看**才有意义——`deploy_kit` 37 个里 35 个是产出的;而语料中段 + 每个 kind 只有 2–4 个产出,**且恰好由 `mock.sh` 的引号修复加 + `env_render` 的 `environment.yaml` 完全解释**——正是「拷贝完再失败」会留下的东西。 + **所以:计数要分 kind 引用,永远不要引总数。495 这个数字应该停用。** + + 那次运行里**唯一不可伪造的事实,是它唯一说「不」的那个**: + `stock_vs_m2` 读了 m2 的 bench、测了 stock 臂、算出 `-0.278058`。 + **失败之后只做了拷贝的 body 产不出这个数。** + +10e. **过于难调的 validator 单独记录上报,不要在跑通前纠缠。** + +## 提交与算力节省 + +17. **暂时不要 push。**(用户 2026-09-05)本地 `git commit -s` 即可,**不要 + `git push`**。DCO 签名照旧。 + *当时的状态:本地与 origin 齐平,说明有人一直在推,别人的提交也跟着上去了—— + 所以这条必须传到每一个会提交的人,不是只有我遵守。* + +18. **尽量少跑第四阶段(kernel opt),跑通一次就复用结果。**(用户 2026-09-05: + *「单次耗时太长了」*) + **实测支持这条**:一次 campaign 在 `forge_max_hours=1.0` 的预算上跑了 113 分钟 + 还停在准备阶段,三个标记全无、源文件一个没改。 + **复用的机制已经有了,用它,不要重跑:** + - `--var mock_stages=…,m4,…` 让阶段 4 回放已封存的 `kernel_optimization`; + - `--var forge_mock=1` 跳过整个 campaign(仍会真跑 STEP 4/5,分钟级), + 产物三层自声明 mock; + - 现成可复用的产物:`/home/yihou/cheat_for_mock.20260905T115337/stage4-kernel-opt/` + (m3 的算子 + 一行注释,`speedup: 1.0`,schema RC=0,readme-cn 审过)。 + **只有在要回答「阶段 4 真跑会怎样」时才真跑它,而那个问题今天已经答过一次。** + +## 记录 + +11. **框架的 bug,无论修了没修,统一记进 `bug.record..md`。** + (在 `agent_sys/examples/llm_e2e_performance_optimization/` 下。) + +12. **调试过程中每一次 validator 失败都要记下来**,便于后续分析。 + (`validator.failures..md`,同目录。) + +## 工作方式 + +13. **小事别找我确认**,用户不一定一直在。 +14. **工作用英文,向用户汇报用中文。** +15. **算力宁可撞墙也别空着。** 一段跑不完的时间照样有价值——它能提前撞出问题并 + 记录下来。用户 2026-09-05:*「可以发,这样遇到问题可以及时发现记录。」* + +16. **巡检周期 25 分钟**(用户 2026-09-05,由 10 分钟改)。 + + **改周期会改变别的规矩,不能只改数字。** 「首次出现只记录,第二次仍未解决 + 才介入」是按 10 分钟校准的:两次 = 20 分钟。**25 分钟一轮,两次就是 50 分钟。** + 今天有实例说明那太贵:一台卡死的引擎占着四张卡、一条线用错包空转一小时、 + 237 有四张卡闲了 16 分钟。 + 所以按代价分档,而不是一律等第二次: + - **占着 GPU 的问题(卡死、撞车、空闲卡组、hold 快到期)——首次就介入。** + 这类的代价按分钟计,而且是不可回收的。 + - **不占 GPU 的(文档、记录、判断分歧)——维持「首次记录、二次介入」。** + - **每轮必做:`sh assets/lib/lines.sh`**(不要临场拼这个检查,见下方 + 「不要相信工具」),以及 `squeue -u $USER` 算**剩余**时间。 + +--- + +# 调试技巧 —— 每一条都是当天赔出来的 + +> ## 一条线看起来不动时,按这个顺序读(m4,2026-09-05,今天全部代价的总结) +> +> | 读什么 | 回答什么 | +> |---|---| +> | 1. phase 行 | **什么被调度了** —— 不是「在跑」 | +> | 2. run tree 最后一次写入(`find -mmin`) | **有没有事情在发生** | +> | 3. 该阶段「第一件产物」的断言 | **哪一步从没开始** | +> | 4. transcript / `store/event` 的 `attributes.detail` | **为什么停了** | +> +> **顺序是有意义的:每一步只有在前一步答不出来时才付出代价。** + +> **停下来的那一步是哪种「投不出去」,事件类型直接说了,不用打开别的东西** +> (m2,2026-09-05): +> +> | 事件 | 执行体 | 记录 | 为什么投不出去 | +> |---|---|---|---| +> | `escalated` | 程序体 | `2a5b4e8` | 从来没有 agent 可以指示 | +> | `handling_failed` | ai 体 | `f3f8f77` | agent 已完成,`mainloop` 已返回 | +> +> **两者的现象完全一样——任务停在 `running`,没有人再试一次。** +> 而**第四步之前**就能分出是哪一种。 + + +> 而最后一步是唯一能给出「原因」的——`message` 在那些事件上是 `None`,原因只在 +> `detail` 里;`kind: ai` 才有 transcript,**十一个叶子里七个是程序体,没有**。 +> +> **动手之前必须走完这四步。** 我 2026-09-05 只看了进程表就杀了一条线,而它离 +> 阶段 5 只有六分钟——日志里写着,我没读。 + +> **先读这一条,它决定下面每一条能不能生效。** +> +> **一条规则有三种形态,只有第三种会衰减:** +> 1. **改变命令** —— `tail -2` 而不是 `tail -1`;`NOW=$(date -u)` 然后引用它; +> 绝对路径 + 不用 `&&` 跨过会静默失败的步骤;可用的 `pgrep` 模式。 +> 2. **改变产物必须包含什么** —— `runtime.replayed_from` 必须存在,而消费者在它 +> 缺失时拒绝;`--node` 不匹配就 rc=2;**一个负控制必须「弄坏 → 放回 → 通过」**; +> 每条验收主张都要点名一个文件和一个会失败的条件;产物里要有出处小节。 +> **这一类不靠记忆,因为「有没有」是可查的。** +> 3. **要求人记得** —— 「读产物不读退出码」「先测分母」。**只有这一类会衰减。** +> +> **推论(readme-cn):当一条教训无法变成命令时,下一个问题不是「写得更醒目一点」, +> 而是「产物里要有什么,才能让这件事变成可查的?」** +> +> **第四个轴(m2,2026-09-05):同一个事实,存在 bug 记录里是惰性的,存在发车块里是 +> 承重的。** 我把 `GPUS=` 记了三遍(flag 失效、两个 m5 不能共节点、m5 的臂吃 +> `0..TP-1`),**都没推出「全真链必须取 0–3」**;m2 一步推出来了,理由是 +> *「我只是因为正要写一条发车行才推出来的——你是在给 `mix_up.sh` 归档一条性质, +> 我遇到的是『我该敲哪几张卡』。」* +> **所以问题不只是「能不能变成命令」,还有「它存放的位置,离用到它的那一刻有多远」。** +> 这正是前置条件 2b 该在发车路径里、而不是在 bug 记录里的理由—— +> **和谁更用心无关。** 这就是「mock 不许断言生产者 +> 没有的事实」变成 CONTRACT §5.3 加一张表、「缺席不可见」变成第二遍审计的路径。 +> +> *代价:readme-cn 今天第二次撞上「`git commit` 因 pathspec 加倍失败,而 `&& push` +> 照跑」——第一次之后他已经写下了教训。他的精确结论不是「写下来没用」,而是: +> **一条必须在复合命令中途、在时间压力下、在你正想着内容而不是机制的那一刻触发的 +> 规则,形状就是错的。** 修法之所以有效,是因为它取消了那个选择的时刻。* + +## 循环的形状,比循环里的努力重要 + +- **一次全链运行只返回一个 bit。** 图停在第一个 invalid handoff,所以串行跑链 + 每次只暴露「下一个错」。*代价:两天,换回一个「rung 1 通过」,而 rung 1 就是 + stage 1 单跑。* +- **先跑最便宜的那个实验。** 同样的信息量:并行探针 3 分钟,串行全链 48 小时。 + mock 在前、GPU 在后;能在登录节点判的,不要占卡。 +- **能并行发现的,不要串行发现。** 阶梯(逐级提升)的唯一作用是可归因,而 + **输入劫持并行注入同样可归因**。`mock_root` + `mock_stages` 就是劫持机制。 + +## 不要相信工具,先证伪它 + +- **我的探针一小时内被 owner 抓出四个缺陷**:空 args(26 行平凡通过)、 + README 取名导致索引到两天前的旧语料、`${var:-default}` 未替换(10 行崩溃被 + 读成驳回)、`overrides` 参数从没被传。**每次都是 owner 在自己的材料上复现后 + 告诉我的。** +- **在多人同时编辑的文件里,永远引用可 grep 的锚点(标题文字、表格首格),不要 + 引用行号。** readme-cn 的 L2133 在「他读到」和「对方读到」之间漂了 31 行,其中 + 20 行是他自己那次提交造成的——**计算时正确,送达时错误,而且是他自己弄错的。** + 今天这个仓库里行号的保质期是个位数分钟。 + **三条轴,而第三条的失败方式不同**(readme-cn):**时间**——裸行号;**空间**—— + `campaign_config.py:316` 不带 checkout 路径;**分配**——从陈旧清单里取的 T 编号。 + **行号会漂移,标识符会撞车。** 漂移的指针误导一个读者然后自我纠正;**撞车的编号 + 把两个发现塞进一个名字下,而且从有人引用它的那一刻起就不再便宜。** + 推论:往只追加的共享文件里追加之前,**在那一刻重读标题清单**,不是每会话一次。 +- **消息可以撤回,提交不会。** 一句关于 `--kernel` 的错误说法在 90 分钟里流通了 + 三手(m4 提出→自己撤回→我转发→m3 写进代码作为修复理由)。**抵达持久化存储的 + 恰好是错的那一个。** 而且它落在最糟的位置:**一个由假理由支撑的正确修复,会被 + 下一个认真的人拆掉**——他去核理由,发现不成立,于是移除守卫。 + **更尖的一版(m3):代码被改回来了,日志没有。** `f92e42b` 的 commit message + 至今带着那句错话且无法修订。**写 commit message 时要当它不可更正——因为它就是。** +- **`ast.parse` 只证明语法,对名字解析一无所知。** m1 用它验了两次并称之为 + 「verified」,而文件里带着一个 `NameError` 躺在共享树里若干分钟。**没有装 + pyflakes 时用 `symtable`。** 一般形式:**仪器无法以你需要的方式失败,而它的 + 输出不会告诉你这一点。** + **`ast.parse` 和 `symtable` 都看不见实参个数错误。** m1 把十二个 `print(...)` + 机械改成 `_note(notes, ...)`,其中两处把 `file=` 带了过去,而 `_note` 不接受它。 + **两处都在 teardown 的 `except` 块里——所以 `TypeError` 只在「teardown 已经失败」 + 时触发,也就是这条 note 最值钱的那一刻。** 这个包在 `NameError` 之后采纳的两个 + 仪器对它都是盲的;**抓住它的是「真的去调用那个函数」。** + **第三种失败方式(checkpoint 09:28):仪器答对了,而你没看。** 他在同一条命令里 + `date -u` 打出 `09:28:36Z`,却手写了 `09:05Z` 进文件——**读取发生了,就在同一行**, + 失败在顺序:他在读取返回之前就把值写死了。**规矩:`NOW=$(date -u …)` 然后引用 + `${NOW}`,永远不要在读取旁边手写那个值。** 前两种是「仪器在回答别的问题」, + 这一种是「仪器在场且未被使用」。 +- **正面的那一半:一个对坏输入报错、而不是返回空集的工具,让同一个错误代价为零。** + (m5,2026-09-05,今天第三次撞上「复合命令跨过一个 `cd`」——而这次失败得安全。) + 他们的 gate 和 commit 接在一个 `cd` 之后,同时破坏了模块路径和 git pathspec。 + **`git` 把坏的 pathspec 当作错误而不是空集合,于是提交被拒绝,而不是提交了错的东西。** + > **对照本节其余各条:`find` 失败成零、空变量变成全匹配、`command -v` 的错误串非空—— + > 那些工具在坏输入上返回了一个「格式良好的答案」。** + → **选工具、写工具时,优先「坏输入报错」而不是「坏输入返回空」。 + 这是今天唯一一次同类错误没有产生代价,而原因完全在工具的设计里,不在人身上。** +- **错误字符串是真值。** `command -v` 以 `exec: bash: not found` 失败,变量非空, + 于是「这个容器有该程序」。和 `find` 失败成零同形——**只因为答案荒谬才被抓住。** +- **转述时最容易加码的,是别人报告里最谨慎的那一句。** 2026-09-05 我三小时内 + 同形状犯了两次,**两次都是转给同一个人,两次都进了他们的文档**: + + | 同事写的(准确、有边界) | 我转出去的(强了一档) | 一次廉价阅读就能框住它 | + |---|---|---| + | kit 记录 217,而我在 287 上回放它 | 「一份 kit 记录了错误的节点,守卫被反转」 | 读一次 `store/task`:哪次运行产出的 | + | 「这个不一致在 `-noval` 层看不见」 | 「一个**没有任何 validator 在看**的地方」 | 读一次 `check_optimization_shape` | + | 容器 `…r5-09051134` 现在不在节点上 | 「一份 deploy_kit **记录了一个不存在的容器**」 | 读一次那个 run 的 `logs/`:它服务过没有 | + | `apply: NOTE ... The patch is applied on the optimisation's own say-so; M5.1.1 wants...` | 「apply 按优化自己的说法就打了补丁,而 M5.1.1 正是为防这个而存在」 | 读那一行的**前缀** | + + **~~第四种加码方式:把 `NOTE` 前缀剥掉~~ —— 这一条我记错了,而记错的方式 + 正是我当天下午自己立的那条规矩。**(更正:m5,2026-09-05 20:3x。) + m1 说 apply「按优化件自己的说法打了补丁,而 M5.1.1 正是为防这个」, + 然后**自己撤回**,说那行有 `NOTE` 前缀、是有意的降级并声明。**我采纳了那次撤回, + 把它记成一次加码。m5 去读了代码:m1 的第一版更接近对的。** + 调用点的策略是明写的:**缺**集成块 → 一条 note;**有而且不一致** → **硬停**。 + 而实现把两者塌进了同一个分支——`workset_integration` 对四种情况都返回 `({}, why)`, + 三种是缺席,**第四种正是策略说要硬停的那个不一致。** + 于是 manifest 写 `attention_recompute_w_u_fwd`、workset 写 `sampler_vocab_softmax`, + **没有任何东西检查补丁会落在哪里**——拦住它的是 base hash,另一个检查。 + > **所以那不是「有意的降级」,是「一个本该硬停的情形走进了 note 分支」。** + **而我没有核那次撤回,恰恰因为它是自我更正——今天下午我自己写下的第五种豁免: + 一句自责什么审查都得不到,因为怀疑它看起来像在替人开脱。** + *「撤回也可能像主张一样错」这条早就在这份文件里。我今天读过它,并且在半小时后 + 违反了它——正是 m4 那句「一条教训不是因为没被读而失效,是因为你写下它所管辖的 + 那一行时它不在场」。* + + **第三次(16:25),而它换了个失败方式:前两次是把作用域加宽,这一次是把类别 + 弄错。** 底下的事实是**生命周期**(容器真的起过、真的服务过、后来被拆了), + 我说成了**出处**(记录是错的)。**「记录了一个不存在的容器」把责任从消费者 + 挪到了生产者身上,而生产者是清白的。** + 判据便宜到荒唐:那次运行的 `logs/worker.log` 138 KB,里面写着 + `Application startup complete` / `The server is fired up`。 + **抓住它的是 m4,他们拒绝把我的配对写成结论,标为「转述、未核」, + 并点名了那条能定案的阅读——正是我今早写进这张表的那一条。** + **他们拿我的规矩核我的主张,没通过的是我的主张。** + + **但这条记录真正的发现不是「我又错了一次」——是这条规矩第一次自己起了作用。** + (m4 的反驳,我采纳。)*「我点出那次阅读很廉价,恰恰因为今天失手两次的是我; + 而那条规矩之所以在那儿,是你今早撤回之后写下的。**它是按 tier-2 的方式生效的: + 谁都不需要『记得小心』,问题已经写在那里,而且只要一条命令。** + 失手的那个主张恰好是你的,这是关于『下一个主张是谁提的』,不是关于我们两个人。」* + **今天前面每一次同类都是靠某个人恰好谨慎抓住的;这一次是靠写下来的东西抓住的。** + → **一条 tier-2 规则奏效的证据,比又一个失败实例稀有,也更值得记。** + + **两次都不是我没查,是我在「转述」这个动作里跳过了查。** 自己做实验时会核, + 接力时不会——因为报告读起来已经像结论了。 + **规矩(readme-cn 给的机械形式,比我自己那版准):转述一份别人的发现、而且 + 用了比他们更强的作用域之前,跑那一次能证伪「更强版本」的阅读。** + 不是「多查一点」——**是查「加码所依赖的、而原报告没有主张的那件具体事」**。 + 两次都属于同一个窄类,各一条命令: + - 「记录了错节点」← 哪一次运行产出了这份产物?一次 `store/task`。 + - 「没有 validator 在看」← 有没有任何 validator 读这个字段?一次 grep。 +- **今天最接近造成真实损害的一次:一个由正确测量支撑的错误修复,拆掉了正在起作用的 + 那个检查。** 2026-09-05,我干的。 + 我造的产物里 `results/optimized_kernel.py` **不是 Python**——它是 workset 的 + `reference.md`,一篇讲「参考实现在哪」的散文,`ast.parse` 直接 SyntaxError。 + 而 `apply_mode: overlay_files` 会拿它去覆盖镜像里一个真实的 Triton kernel。 + **唯一拦住它的是 `base_sha256` 对不上。而我上节点、用真命令、量了一个真哈希填了 + 进去——把那个守卫拆了。** 每一步都站得住,合起来解除了唯一的阻挡。 + **CLAUDE.md 已有的是「假理由支撑的正确修复会被下一个认真的人拆掉」;这条是它更尖 + 的反面。** + m1 抓住它,靠的是他们差点跳过的一步:*「号已经拿到手了,我还是去核了一下这个替换 + 文件是不是从镜像那份派生的——这一步我差点当成官僚流程。」* + **同一小时里我两次把「值的形状」当成「值的意义」**:先是自己写的「必须 64 位十六 + 进制」守卫,被我自己用 52 个零的测试满足并写进活语料;再是这次。 + **两次都是校验了字段,没有校验那个东西。** + **修法不是加守卫,是让整类不可达:** `--base-sha256`(供给)换成 `--base-file` + (从镜像 `cat` 出来),负载 = 该文件 + 注释头,`base_sha256 = sha256(该文件)` + ——**哈希取自负载,所以不可能与之矛盾**。并加了 `ast.parse` 负控制,用当初漏过去的 + 那个文件验过会被拒。 + **推论:一次 schema 检查看不出一个 `.py` 里装的是 markdown。** readme-cn 审过这份 + 产物、RC=0、结论全对——**而这是关于「一次审计买到了什么」最锋利的一句话,它来自 + 一次通过了的审计。** +- **预先登记:在看到结果之前,把「什么算通过」和「现在是什么水平」都写下来。** + (readme-cn,2026-09-05,`68ad295`。今天方法上最硬的一件。) + 他们在 m1 的收敛结果到达**之前**,把 validator 的四个等级连同**可查的判据** + 写进文档,并且**把当前分布也写死**: + + | 等级 | 判据 | + |---|---| + | 1 | 在 mock 数据上 `result: true` | + | 2 | 在**回放**产物上通过(m4 那两个已被永久封顶在这一级) | + | 3 | ② 且它评判的那个 kind,**由本次运行中真正执行过的阶段产出** | + | 4 | ③ **且**在 `3885050` 里拒绝过注入的故障 | + + > **当前:等级 4 为零。等级 3 为零。** + + 于是六个结果到达时,每个 validator 只剩**一个问题**——「它评判的那个 kind + 是不是由真跑过的阶段产出的?」——**这是一次查表,不是一次协商**。 + + **这就是「印证性的结果核得少」那条的 tier-2 解法:** 不是「记得要区分」, + 而是**判据先于数据存在,所以偏差没有可作用的对象**。 + 推论:同时登记「现在是什么水平」,**这样它就不能被悄悄改善**。 + **而它绑住的不只是「结果太差时放宽」,还有「结果太好时收紧」。** + 2026-09-05 结果到达时 readme-cn 写:*「我本可以论证 `check_command_parses` + 只检查一个 `items/command` 脚本能不能解析,拿它当『端到端』太薄。**但判据是在结果 + 到来之前定死的,而在看着第一个数据点时抬高门槛,正是预先登记要防的那件事。** + 如果判据太松,那是另一场对话,不该由第一个数据点来裁。」* + **他们把它标出来,而没有据此行动。** +- **时间戳三条,2026-09-05 各赔过一次:** + 1. **`--time-style=+%H:%M:%S` 是 `ls` 的 `tail -1`。**(m4)它**只打时间、不打日期**, + 所以一个八天前的 mtime 和一个两分钟前的**渲染成一模一样**。m4 因此把 + `2026-08-28 14:44` 读成「今天 14:44」,并据此报了一条「campaign 三小时后还在 + 安装」——**已撤回。凡是这个时间戳要被拿去和别的东西比较,就打日期**: + `--time-style=+'%F %T'` 或 `long-iso`。 + *和 `tail -1` 同形:自己的命令丢掉了要紧的那个字段,而剩下的输出格式完好。 + 区别在于——`tail -1` 是仪器答错了,这个是**仪器精确回答了我问的那个问题**: + 我问「几点」,它给了「几点」。* + 2. **一份拷贝的 mtime 描述的是源,不是拷贝动作。** 今天两个实例: + `cp -a` 保留源 mtime(m4 的 `KernelForge`),`shutil.copytree` 对顶层目录 + `copystat`(我的 `e2e-flow-noval`,和源目录纳秒级相同,m2 差点据此判它陈旧)。 + **想知道「这份副本是什么时候造的」,产物里得自己写一行**——`GENERATED.txt` + 里那个**读出来的**时间戳就是为这个存在的。 + + **2b. 而反过来的那一半更贵:一份「没有重新生成」的副本会静默陈旧。** + (m1,2026-09-05 22:1x,自己举证。)他们的 `r5m1a` 用的包副本 + `generated_at 16:50:32 / source_commit 7e76735`,**而它本该检验的那个修复 + `8f05a94` 是 17:23:21 落地的——晚 33 分钟。** 副本里 `grep -c -- '--environment'` + = **0**,源里 = **4**。于是 `check_environment` 正确地拒绝了 + `integration_report`,**而那是对原始 bug 的正确拒绝,不是对修复的检验**。 + > **「重新生成的树是一件新产物」已经写在这里;它的逆命题才是陷阱—— + > 没有重新生成的树也是一件旧产物,而运行本身不说它跑的是哪份代码。** + **他们差点把它报成「修复没生效」,而判据是一次查表:`GENERATED.txt` 里的 + `source_commit` 就是为此存在的。** 修法是 tier 2 而不是记忆: + `r5m1b` 在 `source_commit ≠ HEAD` 时**直接 abort**,理由写在脚本里—— + *「一份陈旧的副本静默失败:这次运行检验的是你当时有的代码,不是你现在有的代码。」* + 3. **一个落在未来的时间戳,是唯一一种不可能是「读到了旧值」的错。** 它只能是 + 时钟差或读错,所以**它自证有问题,只要有人拿它跟钟比一下**。今天这个判据 + 响了两次:checkpoint 那个快 66 分钟的,和 m4 这个快 47 分钟的。 + **陈旧的值看起来合理、能活下来;未来的值活不下来——前提是有人去比。** +- **一个带默认值的出处字段,记录的是「没人说过」,而不是出处。** + (m3,2026-09-05,举证的是他们自己。) + `scaffold.py:372` → `os.environ.get("E2E_PACKAGE_COMMIT") or "unknown"`, + yaml 里默认成字面量 `unknown`。**今天流通的每一份 workset 都记着 + `produced_by.commit = 'unknown'`**,包括 11:19:40 那份真机产物——它已经嫁接进 + 语料,并被 m4 和 m5 消费。 + **commit 是可知的**(包就是一个 git 树,生产者在里面跑),**但这个字段在等人 + 想起来传一个 `--var`。五次发车,零次。** + 而写下那条 yaml 注释「声明它不解决问题,供给它才解决」的,正是同一个人。 + **这比「缺了就中止」的那类更糟:它不中止,它安静地写下 `unknown`,所以没有任何 + 失败去提示谁。** + 修法是**从树里导出**而不是换个默认值。一个必须记住的注意事项(m3): + 在生产者里 `git rev-parse` 记的是**被 stage 的那份副本**的 commit,而包是按任务 + 重新 stage 的——**所以一条跨越了「代码落地」的链,不同阶段会记下不同的 commit。 + 那大概是对的,而且一定令人意外**,应该写进字段说明里,而不是留给人日后发现。 +- **一份手工订正过的记录,恰好在「有人想到的那些字段」上是对的。** + (m3,2026-09-05,而且这条直接适用于我今天那份语料。) + m5 手改了 `node`、`gpu_devices`、`image_id`,漏了 `node_ip`。数一下就明白为什么: + ``` + environment.schema.json fixed.* 19 个属性(8 个 required)+ runtime.* 9 个 + _agree_or_die 守卫的 3 个:fixed.node / runtime.slurm_jobid / runtime.transport + ``` + **`node_ip` 在 `fixed` 里、不在 required 里、不被守卫。** 唯一留错的那个字段, + 恰好是两边都没有检查的那个。 + **所以正确的结论不是「把其余字段也查一遍」,而是:除了「有人想到」之外, + 唯一的结构性防御是那个一致性检查——而它覆盖 28 个字段里的 3 个。** +- **`gpu_devices` 被发现、`node_ip` 没有,真正的原因是「物理后果」而不是「巧合」。** + (m3 自己推翻了自己一小时前的解释。) + 他们先说是「两个人想要同样的四张卡」。**那是较弱的一半。强的一半是: + `gpu_devices` 有一个第三方会绊倒的物理后果;`node_ip` 只在有东西去寻址错误主机时 + 才有后果,而那时它表现为一个发生在别处的连接错误。** + **巧合让它被发现得「快」;物理性才让它「可被发现」。其余二十五个字段两样都没有。** +- **一个在「输入缺了一半」时保持沉默的守卫,是今天反复出现的那个形状。** + m3 对扩展 `_agree_or_die` 的提醒:它在**不一致**时拒绝,而**环境值缺失被当作一致**。 + 所以给 `node_ip` 加守卫,只在操作者也传了 `--var node_ip` 时才抓得到陈旧记录。 +- **杀掉编排进程不会杀掉它的 `kind: ai` agent,而 agent 会继续创建容器。** + (m5,2026-09-05。今天操作上最危险的一条。) + 实测:14:59 杀编排 + `docker stop` 两个臂容器 → 卡开始释放 → **15:00:09 一个 + `RestartCount=0` 的全新容器出现**(不是 docker 重启,是 agent 新建的)→ 15:08 + cards 0–3 回到 75%。那个 agent 在编排被杀 12 分钟后仍然活着。 + **`docker stop` 一个活 agent 的容器不是拆除,是一个它会撤销的步骤。** + 更糟的是:编排没了,所以这条运行**对任何看 `run_with_long_stall` pid 或 run 日志 + 的巡检都是不可见的**,而它照样占着一个节点的卡。 + > **顺序:先杀 agent,再停容器。** + > 找 agent:`pgrep -f 'claude/versions'`,再 `readlink /proc//cwd` 和 run + > 目录对照——**每个 agent 的命令行长得一样,cwd 是唯一判别器。** + *我今天杀过七个编排进程。查过了:八个活 agent 全部对应五条活着的线,没有孤儿—— + 但那是因为登录节点那几条的 agent 自己退了、088/287 那两条的节点已经没了。 + 换一个时机就不是这个结果。* +- **一个和「你本来会传的值」相同的默认值,会把一个失效的 flag 藏起来。** + (m3,2026-09-05,今天下午最尖的一条。) + 阶段 1 被 mock 时,两条臂从**语料 kit 的环境记录**里取 `fixed.gpu_devices`—— + 那份记录封于 088,写的是 `[0,1,2,3]`——**而 `--var gpu_devices` 根本不进来**。 + **m3 今天五次 mock 发车全都要的是 `0,1,2,3`。所以 flag 在五次里都是失效的, + 而错的答案和对的答案是同一个数字。** + 它不在「你要语料那组卡」时露馅;**它在有人要 4–7 时才露馅——而在双线节点上 + 那是一半的发车。** m5 抽到了另一半。 + + **机制更正(m5,2026-09-05 傍晚,而且是用一次干预测出来的,不是读出来的): + 和 kit 无关。** 他们**把 kit 改成 `[4,5,6,7]`,下一条臂照样去了 0-3。** + 真正的那一行是: + ```sh + assets/serve/mix_worker.sh:26 + GPUS="${GPUS:-$(seq -s, 0 $((TP - 1)))}" + ``` + `grep -rn 'GPUS=' assets/` **只返回这一行,包里没有任何地方设置它**。 + 所以 `TP=4` 时 m5 的臂**永远吃 0,1,2,3**,没有任何 `--var` 到得了。 + **这条比原来那版强,因为它是「改了输入、观察输出没变」——一次干预, + 不是三个人读同一类源码。** 对照本文件「来源独立不等于方法独立」那条。 + *后果:m5 在共享节点上只能是低四张,或者改 `assets/serve/`。* +- **前置检查要数「破坏性那一步真正枚举的东西」,不要数它的代理量。** + (m1,2026-09-06 00:0x,自己的检查拦下自己之后,**改了谓词而不是豁免检查**。) + 他们写的是「节点上零容器」——**那是代理**。`reset_gpus.sh:37` 真正遍历的是 + **持有 `/dev/kfd` 的 pid**。实测: + ``` + 217 上持有 /dev/kfd 的进程:0 + yihou_m3_explore devices=[/dev/kfd /dev/dri] 而 docker top 只有一个 `sleep` + ``` + > **能看见设备不等于持有句柄。** + **数容器会两头错:**把他们挡在一台空节点外去保护一个从不处于风险中的东西; + **而更糟的是,它会在「某个容器持着句柄但 VRAM 读 0%」的节点上放行**—— + 正是真正要防的那一种。 + 改法:~~数破坏性步骤枚举的那个量;容器只作为信息打印,不参与放行。~~ + + > **【45 分钟后被推翻,而推翻它的是 m1 自己在一台已知繁忙的节点上开的那一枪。 + > 上面那条「改法」是错的,照做会造成实际损害。】** + ``` + 00:46:06 VRAMSUM=272 KFD=0 ← 节点确确实实在忙,谓词说零 + ``` + **`spur exec` 的 `/proc` 看不进别的容器,所以 KFD 计数在跨容器时恒为零。 + `docker top` 也救不了——同一次探测对三个容器返回空,而我半小时前用同一条命令 + 拿到了非空:它不是稳定地瞎,它是不稳定,那更糟。** + **真正跨得过命名空间的是 `rocm-smi`**(它读 272 而 `/proc` 读 0), + **所以一直在干活的是「八张卡全零」那道检查,不是新谓词。** + > **m1 自己的诊断,比现象重要:「我用一个**拒绝不足**的谓词,替换了一个 + > **拒绝过度**的代理量,而理由听起来更好——**拒绝不足才是危险的方向**。」** + **这是 `base_sha256` 那个形状同一天第二次:每一步都站得住,合起来拆掉了 + 真正在起作用的那道守卫。** 而 `r5m1c` 发车安全,靠的正是**他们没动过的那一个**。 + **诚实的谓词是两者兼有、缺一不可**:每卡 VRAM(跨命名空间)**加**容器清单 + (覆盖「起机前持有句柄但 VRAM 仍为 0」的窗口),而后者**按「有没有能用 GPU 的 + 容器」放行,不按「我能不能看见它的进程」**。 + *他们拒绝在 00:50 重写它,理由值得抄:同一个文件已经错了两次, + **该上线的版本必须是有人拿一台已知繁忙的节点验过的**,不是推理出来的。* + *另附两条:**一次前置检查无法自己重跑**——`yihou_m3_dev217` 是发车之后才起来的, + 2b 在发车时是满足的;以及那个只报不拦的容器清单顺带暴露了 `charming_yonath`, + 非我方、不占卡,**按「只杀 GPU 占用」不在范围内,没有动它**。* +- **一个针对「你打算用的资源」做的前置检查,查不出「你的意图不会被执行」。** + (同上,m3 推翻了自己一小时前的说法。) + 他们原先说「前置检查抓住了它」。**没有——前置检查抓住的是撞车,不是机制。** + 如果今天分给他们的是 4–7,他们的 `rocm-smi` 会**通过**,因为他们读的是自己 + **要**的那组卡,不是即将真正占用的那组。 + > **那个检查比的是「我的意图」和「世界」;它不会告诉你「我的意图不是软件将要做的事」。** + 推论:**同一份记录还带着 `node`、`node_ip` 和 image。** 卡之所以被发现,只是因为 + 两个人想要同样的四张;其余字段没有这个巧合。 +- **意图不是观察,而清单里两者都要有——它们不能互相替代。**(m3,2026-09-05。) + `--var gpu_devices` 是**意图**,`rocm-smi` 是**观察**。2026-09-05 它们在一台 + 节点上不一致:m5 的线声明 `4,5,6,7`,引擎却坐在 `0,1,2,3` 上,每张 232 GB。 + **而我的 `lines.sh` 读的是声明,我拿它当占用检查用了一下午,并据此告诉别人 + 「哪些卡是空的」。** m3 在发车前跑了 `rocm-smi` 才拦住——否则一台 TP4 引擎会压在 + 今天走得最深的那条阶段-5 链上。 + 改名成 `DECLARES` 只修了标签;**真正保护下一次发车的是「前置清单里两个检查都在, + 而且不互相替代」。** + *我造那个脚本,正是为了取代一个给过四次错答案的占用检查。它回答的是另一个问题。* +- **最贵的一类:没有任何仪器说谎,错的是从它们做出的推断。** + 这一节其余每一条都是「仪器在回答别的问题」——`tail -1`、自匹配的 grep、 + `docker ps` 的 `Up`、空 `logs/`、`find` 失败成零。**下面这两个不是。** + 2026-09-05,同一小时,两个人,同一类的两个方向: + + | | 看到的(都是真的) | 推出的(错) | + |---|---|---| + | m4 | `kernel-agents` 进程活着;`forge_experiments/` 在增长 | 「campaign 在跑」——实际 113 分钟卡在准备阶段,一个源文件都没改 | + | 我 | 进程表显示两条线压在同一组卡上 | 「杀掉后起的那条」——而它离阶段 5 只剩六分钟,日志里写着 | + + **两个仪器都没坏,换更好的仪器也救不了。** m4 的原话:*「唯一能救的是打开 + 那个目录。」* 我的对应动作是:**动手之前读那条线的日志,不只读进程表。** + **操作准则:当你要基于「它在动/它没在动」采取不可逆动作时,先看它在做什么的 + 那个产物——目录内容、日志、`store/event` 的 `attributes`——再动手。** +- **有些事实,从一次运行内部原理上看不见——判据在运行之外。** + 今天两个实例,形状相同: + 1. **镜像**(m3,237):节点上的 tag 指向 `6440587dc7ba`,而所有 kit 和语料 + 记录的都是 `4601539c0f3d`。**但这条链的 m1 是真跑的,所以它会把 + `6440587dc7ba` 铸进自己的记录,下游每一个检查都会与自己一致。** + 不一致只在**和别的运行比**的时候才显现。 + 2. **节点消失**(m3,088):`store` 里任务仍是 `running`,`ps` 里编排进程仍活着。 + **没有任何产物能区分「running」和「running 在一台已经不存在的机器上」。** + 活动证据是 `squeue -j `,**完全在运行之外**。 + **所以「读产物不读退出码」有个边界:产物只能告诉你它自己知道的事。** + 遇到出处/环境类问题时,先问「这个判据在不在这次运行里」——不在,就别在里面找。 + (m5,2026-09-05 收尾时自己给的诊断,今天最值得带走的一条。) + 他们九个配置没跑到阶段 5,其中**四个死于本可预知的东西**——缺 wrapper、 + `aiperf_trace`、`mock_stages` 没配 `m_agent`、stub 入口。 + **而其中两条是他们自己几小时前写进任务 brief 的。文档没有保护住启动行。** + *同类:`--var transport=spur` 我加进了变量表和一个块,没进那个真被当范本的块, + m3 照抄后两小时才发作。`--var deploy_entrypoint=scripts/stub/deploy.sh` 在封存 + 的 kit 里一直存在,没有任何东西指向它,于是三次四十分钟的等待。* + **推论:凡是「没有默认值、缺了就 abort」的变量,清点一遍,逐个进标准块。** +- **「多核一点」不是修法;「核那个能杀死它的具体问题」才是。而对一条出处主张, + 那个问题永远是同一个:这份产物是哪一次运行产出的?一次 `store/task` 读取。** + (readme-cn 给我 2026-09-05 那次撤回的诊断。) + *代价:我把「kit 记录了错误的节点」放大给全队,二十分钟内进了一个提交。 + **而反证在我自己的记录里躺了几小时**——我当天早上亲眼读过那条命令行 + `pid 2394563 --var node=crsuse2-m2m-217`。它不是「没查」,是**没把已有的事实 + 和这条主张连起来**,因为它印证的正是我早已相信的那个限制。* + **推论:撤回也可能像主张一样错。** readme-cn 在执行我的撤回之前,先自己去 + `store/task` 核了一遍才动手。 +- **一个关于「将来不会」的主张,不能建立在对过去的计数上;计数只许可 + 「至今没有」。**(readme-cn,2026-09-05,自己赔出来的。)他们把 + `check_deploy_serves` 记成「0/55,**永远**不会有理由」——**五小时后 m1 的 + `01ed8dd` 就推翻了它**:12/74,最近八次判定全带报告。**一个已被修复的缺陷, + 被写成了一条不可修复的属性。** 他们随后把 `check_environment` 条目里的「永远」 + 也删了,尽管它的数字仍然是零。 + **推论:改正要落在「读者最先撞上的那一处」**,不只是汇总段——他们自己的发现 + 正是「更正到不了快路径」,所以两处都改了。 +- **重建是对共享文件的破坏性操作,哪怕它里面每一处改动都对。** checkpoint 重新 + 生成失败账本(142+/179−),每一条都是改进,却把 readme-cn 同一小时里的五处 + 更正一起生成掉了——**提交本身无法区分「正确的重建」和「错误的合并」**。 + 同形状:`git commit -- ` 取的是工作树。 + **推论(2026-09-05 修正,原写「一个文件只能有一个 owner」,leader 已推翻): + 需要一个 owner 的是「重建」,不是「追加」。** `todo.md`、`bug.record` 是追加, + 保持共享;账本是重新生成的,归一人。**真正管用的是前置检查而不是归属**—— + 写共享文件前先 `git diff`:**前置检查查的是世界,归属查的是协调者的记忆。** + *代价:归属规则在宣布它的那条消息里就已经是破的——`todo.md` 当时正躺着 m3 的 + 48 行未提交改动。* +- **对别人的运行动手之前,读 transcript,不是读 phase 日志。第二次,同一天,同一个我。** + (2026-09-05 16:42:20,我杀掉 m1 的线,**距离它封存还有三十秒**。) + 我读了编排日志:最后一次写入 16:07:25,只有 phase 行,于是判定 + 「`deploy_and_prove` 在跑,进度正常」。**m1 读 transcript,一遍就拿到真相:** + 16:38–16:40 deploy OK、kit 已写、16:41:29-44 三次 Edit、16:41:53 最后一次节点检查。 + **一个已经完成的 bring-up,agent 正在做最后一步核对。** + 最难看的一句是我自己写在通知里的:*「我读了你的日志,而不是照进程表动手」*—— + **我读了错的那个产物,还把它当成尽到了义务。** + **我考虑过「等它封存再杀」,以「依赖时机的保护是最差的一种」否掉了。** + 抽象地说没错,**在这里错了:transcript 会把它从「依赖时机」变成「依赖状态」—— + 「等封存出现,然后杀」是一个条件,不是一次猜测,而我不知道它有多近,因为我没看。** + 代价:35 分钟完成的工作在入账前三十秒被毁,`--resume` 在半开的 handoff 上死锁, + 只能整段重跑。 + **和 13:11 那次同形、同一个人、同一天第二次。** + → **不可逆动作之前的最后一步,是打开那个能说出「它在做什么」的产物。** +- **我把同一个容器先后判给了两个人,而两个人都说不是自己的。** + (2026-09-05 16:50–17:48,我干的,m1 用**我自己的两条消息**指出来。) + 容器 `yihou_e2e_sgl_serves-dcd949cd`,起于 `16:48:05.667Z`: + - 16:50 我告诉 m3「这是你的引擎」——**判据是 `HIP_VISIBLE_DEVICES=4,5,6,7`, + 那是卡号,不是归属**; + - m3 说不是(他们的运行 16:41:43 就死了); + - 17:5x 我告诉 m1「这是你 `w17m1c` 拆除时漏下的」——**判据是它比他们发车晚 2 秒**。 + **m1 的反证是把我两条消息并排放:「起于 16:48:05.667Z」和「m3 的引擎 16:48:05 + 起来」——同一秒。** 而且机制上不可能是 `w17m1c` 的:那条线活了 110 秒、 + 从没离开 `deploy_and_prove: input_validating`,而 `check_deploy_serves` 在 + **输出**验证阶段才跑,且在 `keep17` 里没有挂载;卡组也不对(它声明 0-3)。 + > **两次都是从「相邻」推「归属」:一次相邻在卡号上,一次相邻在时间上。** + > **而如果 m1 照我说的做了,被停掉的会是 m3 的活引擎——正是前九十分钟 + > 全部用来保护的那个东西。** + **诚实的位置是:我不知道那是谁的,而我告诉过两个人是他们的。** + + **第五个实例,2026-09-06 00:5x,而它是我在把这一条写进文件之后犯的。** + 我从 `yihou_m3_dev217` / `yihou_m3_explore` 的名字推出「m3 的容器」, + 并**在同一分钟里对两个人这样称呼它们**——给 m1 的免停通知,和给 m3 的前瞻警告。 + **m3 用「这个包能创建的完整集合」反驳:** + ``` + identify.py:638 docker run --rm --name yihou_m3_identify_ + mock_adapt.py:338 docker run --rm --name yihou_m3_imagefacts_ + measure_in_container.sh:480 docker run --rm --name $E2E_MEASURE_CONTAINER + ``` + **三个名字,全部 `--rm`、全部带 pid 后缀——而一个 `--rm` 容器不可能作为 + `sleep` 存活。** 加上他们的 hold `112862` 已经超时,**他们此刻根本没有路径上那台节点。** + > **`yihou_` 是我们所有人共用的前缀,`m3_` 被读成了归属。** + **这是今天第四个被按前缀判给 m3 的容器**(此前 `src`/`dev`/`reader` 在 287), + **同一签名:`autoremove=false`、`sleep N`、只有 ubuntu 标签、无 `infera_e2e_run`。 + 同一个未知的创建者,现在出现在第二台节点上——至今无人认领,而且不是我们任何一条流水线。** + *m3 同时更正了自己给 m5 的一句过宽的话:包**确实**会造 `sleep infinity` 容器—— + `mix_up.sh:111`,带 `--device=/dev/kfd`,但那是 m5 的 serve 路径、名字是 `$CTR`、 + 带 `infera_e2e_arm`/`infera_e2e_run` 标签。**正确的判别器是那几个更窄的: + `--rm`、pid 后缀、标签——不是前缀。** + + **同一天第三次,而这次相邻在「样本里恰好共变的那个变量」上**(2026-09-05 20:4x, + T78,m4 推翻)。两条真实链的 `apply_patch` 失败、两条 mock-1-4 的通过,于是我 + 写下轴是「真实 vs 回放」。**同样四行完全符合「算子」这个解释,而算子就在每一份 + 产物里,我从没检验过。** + **更难看的是那个前提:「回放的那份能过」是我自己的一手观察,而它是错的**—— + `133147` 回放同样失败(配对错位,manifest 缺 `operator_id`)。**我把一个自己 + 没查过的对照交给了 m4,还附上「注意 `operator_id` 会静默匹配第一个算子」的提醒 + ——那个字段就躺在我的对照里。** + > **前两次是把「相邻」当「归属」,这一次是把「共变」当「因果」。 + > 判据同形:命名一个轴之前,先问「我的样本里还有什么在跟着变」。** + *而真正关掉这道门的,是 m4 去查了**全部五个**算子而不是失败的那一个—— + 「修那个实例比审计整类更贵」那条的正面实例:五个 baseline 全是 harness 形状, + 于是「换个算子」这条逃生路当场消失。* + + **第四个实例(m5,同一天,而且是这一类最尖的形态):他们从**名字前缀**推出 + `yihou_m3_src` 是 m3 的,把它删了。m3 用 `AutoRemove`、`Created` 和 labels + 证明不是。**而 m5 自己的总结是要害:** + > **我需要的每一个判别器,都在一条我已经跑过的、只读的 `docker inspect` 输出里。** + **不是「没去查」,是「查了、没读」。** 和今天那份躺了一小时的 `ABORT.md`、 + 那份躺了 64 分钟的 transcript、以及 `attributes.detail` 是同一族—— + **答案已经被取回来了,只是没有被读。** + *可用的判据今天已经出现过一次并且是我自己用的:`pgrep -P <编排进程>`—— + 亲子关系。容器侧的对应物是 label 和 `docker inspect` 的挂载,不是时间戳。* +- **`docker ps -a … | head -N` 会把正在运行的容器切掉,而剩下的输出看起来是完整的。** + (m1,2026-09-05;**同一小时我自己也用了 `docker ps --format … | head -4`。**) + 他们的 `head -8` 只显示了 6–19 小时前的容器,于是「节点上没有运行中的容器」—— + **而卡正读着 75%。两个正在运行的排在切口之下。** + 和 `tail -1` 吃掉上一行同族:**自己的命令丢掉了正在问的那个字段, + 而幸存的输出看起来完好。** 判据用 `docker ps -q | wc -l`。 +- **发现一类缺陷之后,修那个实例再重跑,比审计整类更贵。**(m1,2026-09-05,赔了三次发车。) + 他们撞上 `expect_ranks`,**修了那一个变量、重发,然后撞上 `adhoc_cases` 和 + `bench_rounds`**。三个都是同一类:**同一个变量,对被 mock 的阶段是一个值, + 对真跑的阶段是另一个值。** + **修法从来不是「修那个变量」,是「拿整张表逐行对着 mocked 列审一遍」**—— + 他们最后那样做了,一遍就找出剩下两个。**八行里有两行对他们那次是错的, + 而这在第一次发车之前就可知。表本来就是对的,只是没有被当成一张表来读。** + > **把真实值带进一次 mock 的运行,产生的拒绝读起来和生产者缺陷一模一样。** +- **同一个「空值流进破坏性过滤器」的缺陷,同一天出现在两个人各自独立写的脚本里—— + 而第二个被发现,只因为第一个先炸了。**(m1 与 m2,2026-09-05。) + m2 的是 `case "$c" in *"$(basename ${R%/})"*)` → `* *`; + **m1 的 `stop_line.sh` 里,空 `PREFIX` 会让容器过滤变成 `grep '^'`—— + 匹配节点上每一个容器,包括别人的引擎。同样的爆炸半径,不同的路径。** + m1 是拿 m2 那句诊断去对自己的脚本读的:*「我让一个计算出来的值流进了一个 + 破坏性谓词,而没有检查它非空。」* + > **一个类会在同一天独立复现;而它在第二处被发现,靠的是第一处的代价, + > 不是靠更仔细。** + *m1 的加固值得抄:`PREFIX` 必须非空**且**必须含 `yihou`——那正是删除铁律 + 用来判断「是不是我们的」的那个子串;`pid` 必须是数字。四个负控制都跑过, + 不是声明的。* +- **同一套推理,一次得出错误的指控,一次得出正确的提问——差别在于你停在哪里。** + (m1,2026-09-05,自己举证。) + 下午他们从一份 transcript 和一个时间戳构造出「有人扫了 agent」,**指向两位 + 什么都没做的同事**;晚上同样的形状是对的,**而他们把它写成了问题而不是断言。** + 他们自己的话:**「差别不是我的推理,是我这次停在了问句上。」** + *而答案仍然需要 m2 主动报出来——形状对,不等于机制可推。* +- **一个空变量落进通配符,会把过滤器变成全匹配——而这是今天唯一一次伤到别人的错。** + (m2,2026-09-05 19:31:33,他们主动、立刻、带机制地报了出来。) + ``` + R=$(ls -dt .../runs/20260905T193*/ | head -1) # 自己那条是 1929,不匹配,R 为空 + case "$c" in *"$(basename ${R%/})"*) # 变成 * * + kill -TERM 四个 agent # 本意是一个 + ``` + **m1 跑了 22 分钟的 `deploy_and_prove` 和 m5 那条最深的 `integrate_and_verify` + 一起死了**,而后者正是唯一会回答 `check_measurement_order` 的那次运行。 + **他们自己的诊断比 glob 准:「失败不是 glob。是我让一个计算出来的值流进了一个 + 破坏性谓词,而没有检查它非空。`[ -n "$R" ] || exit` 就一行,我没写。」** + > **同一个检查该加在每一个流向破坏性命令的派生值上,不只是那一个变量。** + **而最该留下的是他们对前三次的复盘:那三次只赔了自己的 shell, + 所以这个模式被当成了烦人而不是危险。** + > **便宜的实例会教出关于昂贵实例的错误结论。** +- **「正在收尾」而编排进程还活着的运行,不是在收尾。**(同一小时,m2。) + 他们把一条被自己误杀、显示 `deploy_and_prove: failed` 的线写成了「winding down」。 + **它的编排还活着,而框架可能重试一个失败的任务**——那会在一个有活跃外来租户的 + 节点上,无人看管地起一次新的 bring-up,带着 `--timeout 21600` 和没有天花板。 + *今天第二次:一个被判定「已完成」的进程仍有行动能力。第一次是那个在容器被停 + 一分钟后重建了一条臂的 agent。* +- **一个错的发车变量,在 `-noval` 下活过了整整一个下午,而打开一个 validator + 五秒就把它照了出来。**(m1,2026-09-05,今天关于「关掉验证的绿买到了什么」 + 最具体的一条,而且比抽象版本强,因为那个错值活过了一次所有人都在引用的运行。) + ``` + # check_trace_coverage + note: 2 rank(s), 419218 GPU kernel events + PROBLEM: expected 4 rank(s), the manifest lists 2 + ``` + `RUN-PLAN` 的变量表本来就写着:m2 被回放时 `expect_ranks` 是 **2**(语料是 + 09-02 的 TP-2 采集)。m1 从更早的血统里继承了 `4`。 + **要害不是这个错误,是它藏在哪里:同一个 `expect_ranks=4` 在那次 78 分钟的 + `-noval` 基线里同样是错的,而没有任何东西说出来——因为没有 validator 在看。** + > **打开一个 validator,让一个错了几小时的发车变量第一次可见。 + > 这就是「关掉验证的绿」买不到的东西的实例。** + *附带:`check_trace_coverage` 因此升级了——它在一条真实链上拒绝过一次, + 点名了字段、给出了数字。而且它写报告,所以定位花了五秒,不是十五分钟。* +- **一个字段的职责就是说「这东西来自别处」——它是最不该被洗成占位符的那个。** + (m4,2026-09-05,回答我那个契约问题时给的,而且理由不是可移植性,是证据性。) + 我的 `kernel_optimization.json` 里嵌着 `work_root: /mnt/…/e2e_flow_088a`, + `redact` 因此拒绝。看起来该「把路径抹掉」。 + **而今天最贵的几次调查,恰恰是缺这种信号:** 一份在 093 上流通的产物里写着 + `e2e_flow_088a`,**正是那种需要一次 `store/task` 才能定案的出处信号。** + > **抹掉它就是销毁证据,而不是提高可移植性。** +- **`redact` 的拒绝按文件后缀划界,而它自己的理由按内容性质划界。**(同上。) + `redact.py:56` `REFUSE_SUFFIXES = {".py",".sh",".json",".jsonl"}`, + 而 docstring 说的是*「一个带着某台主机目录结构的脚本,在下一台机器上跑不起来」*, + 并且**有意放过散文**(*「散文里写 `/v1/models` 不会烙进任何主机的目录结构」*)。 + **`premise.*_environment` 在散文那一侧——没有任何东西通过它定位任何东西, + 记录自己的注释就说消费者该读握手。是 `.json` 这个后缀把它归进了可执行那一桶。** + *这就是 T75 那条更正的逐字重演:按种类约束,不要按文件名。* +- **「再加一个前缀」是跑步机,而每一圈的代价是一次真实部署之后的 `exit 1`。**(同上。) + `redact` 的前缀表由**本次运行**的环境装配(`packup.py:515-527`), + 而嵌进去的记录带的是**另一次运行**的根。**从活环境装配的前缀表,永远盖不住外来的根。** + **代码库自己早就知道这件事——`MOCK_ROOT` 存在的理由,正是「一份被回放的 kit + 记着封存它那次运行的根」。** 那是一个已知的外来根;逐字继承产生的是无界集合。 +- **监控器的三种失效,代价从小到大:漏报 < 每轮都喊 < 中途静默死掉。** + (m2 给了前两个,m4 补了第三个,2026-09-05。) + m2:*「一个每个周期都在喊的探针会被无视,而那和『它是错的』代价相同。」* + m4:**「一个中途静默死掉的探针比这两者都贵——运行还在继续,而没有人知道 + 它已经不在看了。」** 那正是 m4 今天下午那四分钟的空档。 +- **不要直接执行共享仓库里的脚本当长驻进程——bash 是惰性读取的。** + (m4,2026-09-05,而且他们据此没有执行我的指示,是对的。) + 改一个**正在运行**的 bash 脚本会移动它尚未读到的偏移量。 + `assets/lib/idle_watch.sh` 今天已经被改过两次——**任何从那个路径直接起的长驻 + 进程,离「中途开始执行垃圾」只差一次别人的编辑,而现象是「探针莫名其妙崩了」。** + **解法不是回到各自复制,而是「带出处的快照」:** + ``` + # SNAPSHOT of .../assets/lib/idle_watch.sh + # taken 2026-09-05T17:43:49Z at HEAD=b8cacab, sha256[0:16]=7d349bb191bae81e + ``` + **它不重开「三份副本」那个问题,因为副本点名了自己的来源、commit 和摘要—— + 陈旧变成可检测的(`sha256sum` 一比就知道),而不是隐形的。** + *时间戳是同一条命令里 `date -u` 读出来的,不是手写的。* +- **一个每个周期都在喊的探针会被无视,而那和「它是错的」代价相同。** + (m2,2026-09-05,把自己 v2 的首版当成回归而不是小毛病。) + 他们修上面那条盲区时,首版用**精确匹配**任务——而 phase 行经常点名一个 + 非叶子,真正干活的是它的子节点,**于是健康的运行每轮都被判 BLIND**。 + 改成沿 `parent` 链上溯,并在发布前用四个用例测过(含这次回归)。 + > **一个探针的假阳性率,和它的假阴性率一样是它的正确性的一部分。** +- **一个在「观察对象不存在」时**换一个对象继续汇报**的仪器,比一个沉默的仪器更坏。** + (m4,2026-09-05,在自己那条死掉的运行上发现,而且推翻了自己写的 header。) + 停滞探针取 run 树里**最新的那个 `.jsonl`**。而卡住的那个阶段 + `run_profiling_mode_off` 是 `${m2_agent:-runner}`——**程序体,没有 transcript。** + 于是它拿到了 m1 的: + ``` + IDLE-AGENT SUSPECT: transcript 845s stale while + 'm1_deploy: output_validating -> succeeded' + ``` + **每个字都是真的,而结论不可用**:它点名了一个**已经成功**的阶段, + 而真正卡住的那个阶段仪器根本看不见。 + **m4 在装它之前就在 header 里写了「对程序体它会永远打 no transcript yet」—— + 实际更坏:在混合图里它不会沉默,它会去报另一个阶段。** + > **沉默是可检测的;换个对象继续自信地汇报不是。** + **读法必须改成「树里最新的 transcript 陈旧了」,而不是「这个阶段的 agent 空转」—— + 两者恰好在「当前阶段是程序体」时分叉。** + *附一条正面的:m4 用 `cmp` 对 `HEAD:` 逐字节核了重新生成的文件, + **而不是 grep 一个关键词——「grep 在旧副本上一样会通过」。*** +- **`bash -n` 只验语法,一条注释放进反斜杠续行块里会吞掉后面的命令,而它照样通过。** + (m2,2026-09-05,自己在发出前读打印出来的块才抓到。) + 续行块里的注释会接到上一行上,**把这条命令的其余部分一起吃掉**—— + **`bash -n` 接受它。** 和 `ast.parse` 同族:**仪器无法以你需要的方式失败。** + 他们的做法值得抄:**注释挪到块外,并在原地写明这个陷阱,然后用行为测试代替 + 语法检查**(回环握手 → 回环;节点 IP 握手 → 节点 IP;无握手 → 组合出的回退)。 + + **同族第三个,而它给出了「什么时候解析救不了你」的判据**(m1,2026-09-05 22:2x, + 修我撞到的那个 `sh` / `bash` 头部错误时)。两个脚本同一个缺陷:头部写 `sh`、 + shebang 写 `bash`、body 用 `set -o pipefail`。 + ``` + dash -n stop_line.sh -> 沉默。通过。 + sh assets/lib/stop_line.sh -> line 56: set: Illegal option -o pipefail + ``` + > **`dash -n` 不可能在一个非法选项上失败,因为它是语法检查,而 `-o pipefail` + > 语法完全合法。** + 而 `refusal_saw_something.sh` **确实**没通过 `dash -n`——它卡在进程替换上。 + **同一类缺陷,一个被解析抓住、另一个只能被执行抓住,这就是「跑一遍」的全部理由。** + *附:m1 是查了整类而不是我撞到的那一个,于是找到第二个从没有人用 `sh` 跑过的脚本; + 两处现在都写明 `bash` 并写明**为什么**,免得被人「简化」回去。* +- **一个只守住复合值里某一个字段的修复,会把它的兄弟字段留在原地——而那个修复本身是对的。** + (2026-09-05 已有三个实例,今天最会重复的一类。) + 1. **`HS_ENDPOINT`**(m4 发现):早先一次 abort 是端口算错,修法是 + **不再计算端口、改为读握手**——**这个修法对,现在也仍然对**。 + 但 `HS_PORT` 从 `HS_ENDPOINT` 里解析出来并被校验,**同一个字段里的 host + 被逐字使用**。于是 kit 记下 `http://:`,而路由器绑的是 + `127.0.0.1`:一次成功的 TP4 起机(227 秒、`verify: PASS`)之后, + 负载步骤拨了一个没人监听的主机。 + 2. **bug 30**(也是 m4):回退把 `work_root` 搬走了,**没搬 `scratch_root`**—— + 两者同前缀、同一条发车行,却不共回退。 + 3. **`_agree_or_die`**:28 个环境字段里守 3 个。 + > **修一个复合值里的一个字段时,把同一个值里其余字段点名一遍, + > 说清哪些被守住了、哪些没有。「这一处修对了」不蕴含「这一类修完了」。** + *附带一条正面的:`line.sh:318` 上方的注释保留了上一次 abort 的运行号和症状, + **m4 因此几分钟就定位到,而不是几小时。修 bug 时最想删掉的就是那个 bug 的故事。*** +- **mock 满足了一个真实路径根本无法满足的检查——于是那个 body 的整个生命期都是隐形的。** + (m5,2026-09-05,今天最尖的一条,而且方向和我们担心的相反。) + `compare.py` **从来没有写过 environment 记录,一次都没有**,而 + `check_environment` 是 `strong`、覆盖全部十五个 kind——**所以 + `integration_report` 这个 kind 从来不可能通过它。** + 为什么没人发现:**此前每一级 rung 的阶段 5 都是 mock 的,而 `mock_m5.sh` + 自己渲染那份记录。** + > **我们一整天担心的是「mock 造出生产者从不写的东西,于是 validator 在虚构上通过」。 + > 而后果比假通过更重:它藏起了一个根本无法满足该检查的 body。** + **只有一份真实的 `integration_report` 才能暴露它,而今天才产出第一份。** + *这正是 CONTRACT §5.3 存在的理由。修完之后他们做了「弄坏 → 放回 → 通过」的控制: + 同一份产物上 `false → true`,而且是跑出来的不是读出来的。* +- **`v0` 是空的不稀奇——凡是重试过的东西,`v0` 空是常态。** + (m5,同一小时内两次栽在这上面。)他们两次从一个空目录推出「缺失」, + 两次内容都在更后面的版本里。**修法是 tier 1:先枚举版本、取有内容的那个,再开口。** +- **一份 `--inherit` 来的记录,证明「调用发生过」,但它不是第二个来源。** + (m4,2026-09-05,主动标出来的。)`optimize_kernel` 的活路径确实写了 + `items/codes/environment.yaml`,而且**有守卫**(输入缺失就 die)—— + 但那是 `env_render --inherit`,**内容是 m1 记录的转发,不是独立测量。** + **正是「一个来源被拆成两份」那种情形**,任何拿下游记录当佐证的方案都要先过这一关。 +- **「没有解释」和「解释算出来了、然后被扔掉」是两个缺陷,修法完全不同。** + (readme-cn,2026-09-05,而且他们去读了代码路径才敢这么写。) + `check_environment` 拒绝三个 kind,报告里**没有任何 `PROBLEM` 行**, + 看起来是「这个 validator 从不解释自己」。**实际上:** + ``` + find_record() 第二个返回值就是 "no environment.yaml at any of [...]" + main() 把它 append 进 findings,然后 print(..., file=sys.stderr) + stderr 被丢弃 + ``` + **句子是算出来的,而且写出来了——写去了一个没人保留的地方。** + > **在断言「没有解释可用」之前,先查一遍解释是不是被算出来又扔了。 + > 前者要有人去写,后者只要一根管子。** + 他们没有把它当新发现立,而是认出它是 `bug.record` 里那条 + **「validator 的 stdout 无处留存」今天第二次计费**。 + *而这也把成本挪了位置:上一次的代价是 m1 花十五分钟把那句话重新推导出来; + 这一次那句话存在——只存在于一份诊断和 T76 里。从 `verdict.json` 出发的人, + 拿到的仍然是三个 `false` 和同一条泛泛的升级信息。* +- **一个拿「本次运行没有使用的值」去评分的工具,会把差异报成产物的缺陷。** + (m5,2026-09-05,自查探针时发现,而且这是今天第三个同族。) + 他们的 `probe_validators.py` 把 `MOCK_VARS` 硬编码成 `expect_ranks=2, + adhoc_cases=0`——对 09-02 语料是对的。**拿去评一条跑了 `expect_ranks=4`、 + `bench_rounds=3` 的链,凭空造出三个拒绝:** + ``` + check_trace_coverage "expected 2 rank(s), the manifest lists 4" + check_bench_report "3 replay round(s) present, 1 expected" ×2 + ``` + 加上 `--var K=V` 之后,**同样的产物、同样的探针:29/13 → 32/10。** + **同族:`mock_stages` 什么都没 mock、`--var gpu_devices` 是失效的—— + 发车行和检查器静默地不一致,而挨骂的是产物。** +- **同一个探针的第二个缺陷:理由全在磁盘上,一条都没打印出来。** + 它取第一条匹配 `PROBLEM|REFUSED` 的行,**而 validator 的报告以 + `## probe-: REFUSED` 开头——所以永远先匹配到标题。 + 十三个拒绝把自己的标题回报了回来。** + **一个「摘要工具」可以在完全不出错的情况下,把它要摘的东西全部丢掉。** +- **一个 agent 写了 4.4 KB 说明自己为什么拒绝,而我们花一小时读进程表。** + (m5,2026-09-05,`integrate_and_verify` 在 15:07 中止,`ABORT.md` 在它的 zone 里。) + **它的判断比当天任何一个人都好:** + - 它拒绝,是因为端口 5557/8801 被**同一个包的另一次运行**占着—— + **正是 m5 自己那条孤儿运行的臂,由孤儿 agent 在 15:00:09 重建, + 距离容器被停只过了一分钟**(「agent 活过编排并重建容器」那条的又一实例); + - **归属按 label 判,不按名字**:`infera_e2e_run=yihou_m5_237c_stock` ≠ 自己的 + `$E2E_CONTAINER`,原话*「不是这个任务创建的名字,就不是这个任务可以删的名字」*; + - **它拒绝执行 `reset_gpus.sh`**(`mix_up.sh:81`)——那是节点级 `kill -9`, + 会毁掉另一次运行的引擎。**同一个隐患,一整个下午耗掉了我们两条线和一次误杀。** + - 它还记下了自己不得不自行供给的两个值,包括 `E2E_GPU_DEVICES=0,1,2,3`, + 并注明**那不是选择**。 + **产物在磁盘上躺了一小时,而我们在读进程表。** 今天第五次同形。 +- **比较节点状态时,引用容器的**启动时刻**,不要引用 `Up N minutes`。** + (m3,2026-09-05,一次「看起来是矛盾」的事后。) + 他们报 `serves-dcd949cd` **Up 6 minutes**,我报它已经没了。**两个都对,相差约 + 90 秒。** 但两个读数**单位不同**:一个是相对时长,一个是绝对观察, + **而相对时长会静默地重新锚定到「你读它的那一刻」。** + > **`Up 6 minutes` 不和任何东西可比,除非你同时知道它是几点打印的。** + 修法是一条命令:`docker inspect -f '{{.State.StartedAt}}'`, + 报 `started 16:48:05` 而不是 `Up 6 minutes`。 + **和 `ls --time-style=+'%F %T'` 同族**——那次是丢了日期,这次是丢了锚点。 +- **审计「某文档有没有写明要求 R」时,grep 的命中不是证据,不命中才是。** + (2026-09-05 一小时内三个实例,两个人。)要求本身的字眼会因为**无关的原因** + 出现在文档里: + - readme-cn 搜 brief 里的 `## Purpose` —— 命中的是 **brief 自己的结构**, + 不是它叫 agent 写什么; + - m2 的三个 brief 里有两个**自带 `## Watch out` 标题**,agent 读了可能 + 以为要求已满足; + - m5 的 brief `:140` 有 `*Watch out:*` —— 一句讲 bring-up 日志的散文。 + **三次都是「像模像样的命中」。** 结构上不对称: + > **不命中能证明「没写」;命中什么也证明不了,必须去读那一处在说什么。** + 推论(readme-cn 做对的):**把这个陷阱写进审计结论里**—— + 「下一个来 grep 的人会撞上 `:140`,那不是它」——否则下一个人要重新推导一遍, + 而且可能推反。 +- **撤回一个推断时,不要把它挂靠的那份一手证据一起撤掉。** + (m2 收尾时做的调和,2026-09-05。) + m4 从「`exit_status: finished` + 死掉的 transcript」推出「agent 已退出」, + 错了,于是连带撤回了「推送没有接收方」并说「我不再猜」。 + **但那句话不是猜的——它是框架自己的异常文本,一手躺在 `store/event` 里:** + *「instruct(...) 没有 loop 可投递。agent 已完成,`mainloop` 已返回。」* + **调和:`mainloop` 返回不等于进程退出。异常描述的是 loop,`ps` 描述的是进程。 + 两个人都对,而且说的是两个不同的对象。** + → **一次过度的撤回,会把当天最好的发现和那个错误的推断一起扔掉。** + *「撤回也可能像主张一样错」已有;这一条是它的具体形态:撤回的边界要和推断 + 对齐,不能和情绪对齐。* +- **`pkill -f <你自己脚本的名字>` 永远自匹配,`[p]attern` 那个技巧也救不了。** + (m2 与 m4,2026-09-05 同一小时各中一次,而 `lines.sh` 的那条教训已经读过八小时。) + m4 的 `pkill -f p5_idle_watch.sh` 杀掉了自己的包装 shell,**连同还没执行到的 + 重启那一行**;m2 同形。**而 `[p]8_idle_watch` 挡不住**——那个字面串在同一条 + 命令行的**别处**还会出现。 + > **tier 1 的形状:先用一个**不同的方法**解析出 pid,再按 pid 杀。 + > 永远不要用一个出现在你自己调用里的模式去 `pkill` —— **`pgrep` 同样**。** + *m2 今天第三次栽在这上面(`pgrep -f "p9_idle_watch"` 杀掉了自己的 shell), + 他们自己的结论:「它现在给我造成的代价,已经超过我用它找到的 bug。」 + 三次都只是因为真正的目标先被杀掉了,活才落了地。* + **第四次不是我的 shell,是别人的活。**(m2,2026-09-05 19:31。) + ``` + R=$(ls -dt .../runs/20260905T193*/ | head -1) # 运行目录是 1929,不匹配 -> R 为空 + case "$c" in *"$(basename ${R%/})"*) # 模式退化成 * * -> 匹配一切 + kill -TERM 292129 553438 615282 651741 # 四个 agent,只有一个是我的 + ``` + 杀掉了 m1 在 217 上跑了 22 分钟的 `deploy_and_prove`,以及 m5 在 237 上 + `integrate_and_verify` —— **那条链当时最深的一点,也是唯一会回答 + `check_measurement_order` 的那次运行。** + > **真正的缺陷不是 glob,是「一个算出来的值,没有校验非空就进了一条破坏性命令」。** + > `[ -n "$R" ] || exit 1` 就是全部修法,**而它属于每一个流向破坏性命令的派生值, + > 不只是 `$R`。** + *两条附带的,都值钱:* + - *前三次只赔了我自己的 shell,**所以这个模式被我定价成"烦人"而不是"危险"。 + 廉价的实例会教出关于昂贵实例的错误结论。*** + - *`143` 以「不明崩溃」的形式出现在我自己的运行日志里——那是我自己的 SIGTERM; + 而**同一个分钟边界**(19:29 vs 19:30)既造出了那个空变量,又让我以为进程表里 + 多了一条新线。* +- **一条走进死胡同的链,默认会留下停驻的 agent —— 二比二。**(m2,两次都实测。) + 217 和 093 的 `p8` 都是:三个 `claude/versions` 停在 `ep_poll`,加上编排进程。 + **所以每一条死掉的链都需要显式拆除,而不是弃置。** + *而 `ep_poll` 停驻本身不是死因特征(m4):成功阶段的 agent 也长这样, + 其中一个已经停了 1 小时 51 分。* +- **「未回答」和「从这份数据里根本够不着」是两回事。**(m3 更正 m4,2026-09-05。) + m4 一直把「它在真实运行里会不会驳回」当成「知道了发车值就能回答」的问题。 + **不能:两条运行都是 `-noval`,运行中根本没有过 `check_trace_coverage` 的裁决, + 只有 `check_nothing`。没有可比的对照,所以那个反事实在两个方向上都够不着。** + > **离线评分确立的是「这个 validator 现在说什么」,而不是「它当时会说什么」。** + *我们今天一直在说「未知,不是失败」——这是更尖的一版:不是还没答,是从这份数据 + 里问不出来。而 m4 把被推翻的那个读法**标出来而不是删掉**, + 让下一个读者看见它当初错在哪一侧。* +- **今天最强的一份证据,是两条真正不同的路径落在同一个数上。** + `items/env/trace_manifest.json` 枚举了 rank 0–3,四条各带自己的字节数、sha256、 + 事件数、时间跨度——**四条的 `gpu_kernels` 加起来正好 823736**; + 而 `check_trace_coverage` 是**从头重新解析 trace** 得到同一个数。 + **一个是清点,一个是重算,两条都不是拷贝。** + *对照前面那条:m2 与 m4 各自重解同一个 trace,那是一个方法执行两次; + 这一次才是两个方法。**判据不是「几个人得到了同一个数」,是「他们用的是不是同一条路」。*** +- **一份冻结记录的可信度,来自「改过什么是可查的」,而不是来自「从没被碰过」。** + (readme-cn,2026-09-05,他们据此才肯动那份预先登记记录。) + 修法把限定词搬进了加粗句内部,并且**在原地记了一条变更日志**:什么动了 + (可引用单位的边界)、什么没动(零个数字、零个等级归档)、以及它和今早被 + 拒绝的「回填」有何不同。 + > **「这份基线从没被编辑过」是一个在它不再为真的那一刻就失效的主张; + > 「这一次编辑做了什么」则一直可查。** + *推论:冻结不是不许改,是不许悄悄改。* +- **先问这个缺陷在源头还是在传输途中——修法完全不同。** + (readme-cn,同一条提交里最锋利的一句。) + > **今天关于这个数字的错误,全部发生在引用它的路上,没有一个发生在产生它的地方。** + 那把梯子产出了一个正确的 0 → 1;**我两次对用户说错、他们把自己的快照读成当前值、 + 四个人接力搬错——全部在传输里。** + **一个源头可靠、传输有损的机制,要的是排版修复,不是机制修改。** + *他们对自己那次的诊断同形:「我查的是限定词在不在,然后就停了—— + 仪器回答的是『有没有写』,而问题是『它会不会跟着走』。」 + 和 grep 一份 brief 自己的标题是同一个错。* +- **加粗的那一行才是会被搬走的单位——限定词写在它上面一行,等于没写。** + (m3,2026-09-05,更正了我对同一件事的诊断。) + 我把它记成「一个读起来像活数字的冻结基线」。**不对:那个基线是标注过的。** + ``` + **目前的分布(2026-09-05T13:xx,结果到来前):** + + - **第 4 级:0 个。** 本包至今没有任何一个 validator 达到过。 + ``` + **「结果到来前」带着时间戳,就在上面一行。** 而限定词在普通正文里, + 主张在加粗的项目符号里——**加粗、带数字、自成一句,那才是可引用单位。 + 它经过了四个人,而上面那一行一步都没跟着走。** + > **这是散文里的 `tail -1`。** 和那四条「死因不明」的运行同形—— + > 死因就写在最后一行的上面一行,而我的 grep 把它扔了。 + **所以这不是文档缺口,是排版缺口:什么被加粗,什么就被搬走。** + → **修法不是改那个数,是把限定词搬进加粗的那一句里。** + (2026-09-05,今天三个人先后把 §2.8 的「第 4 级:0 个」读成当前状态,包括我, + 我还对用户说了两次。) + 那个零是**预先登记的快照,刻意不回填**——而 §2.9 早已归档了一个第 4 级、一个第 3 级。 + **不能为了「保持最新」去改基线,那会毁掉它存在的理由。** + > **所以修法是:在每一个会被当成计数来读的地方,写明「当前值读 §2.9,不要读这里」。** + **而 m4 指出了这句假话真正的落点:他们把它写进了 `grade_offline.py` 的 docstring + ——已提交、在仓库里。** 他们的话: + > **消息是可以撤回的;那一行不会,而下一个人会把它读成当前状态——你就是这么拿到它的。** + → **一句错话在消息里,代价是一次对话;在被提交的文件里,代价是此后每一个读者。** + *他们改之前自己去核了 §2.9,理由是「撤回可能和被撤回的主张一样错」—— + 那条是我今早自己撤回之后写下的。* +- **两条路都终止在同一个字段,就不是两个来源。**(m3,2026-09-05,而且它切的是 + 所有人都想要的那个答案。) + m4 从 `tp_size: 4` 推出 `expect_ranks=4`;m3 的 4 来自发车块的规则, + 而那条规则字面就是 `--var expect_ranks=`。 + **所以这次确认成立的是「那次运行用的是 4」——正是 m4 需要的,因为它意味着 + 他不是在给自己编的参数打分;但它不确认「4 是对的」有两条独立路径。** + **真正独立的那一条被他们点了出来:`items/env/trace_manifest.json` 里 + `ranks: 4`,是部署自己数出来的。** + *今天第三次用上「来源独立不等于方法独立」,而这一次是用在一个便利的确认上。* +- **拿到了正确的答案,却错过了那个一般事实。**(m2,2026-09-05,自己举证。) + 他们建验证 zone 时也撞上了「内容在 `v1/` 而 store 写 `version: 0`」, + **写下了 `.../v1/content`,因为文件就在那儿——却从没问过 store 为什么说 `0`。** + 于是他们的评分是对的,**而那个会让 m4 少掉四个假驳回的一般事实没有被提取出来。** + > **「路径对了」和「知道为什么路径是这个」是两件事,而只有后者会传给下一个人。** + *m4 把它变成了 `grade_offline.py` 里的一个守卫;m2 拿到了对的路径、漏掉了那个事实。* +- **同一个成因的多个实例,会因为「被当成事件来数」而彼此隔离。** + (m4,2026-09-05,给 T79 写的那句。) + > **第五个是在评分时发现的,第四个是撞车撞出来的,第一个是审计出来的—— + > 一个成因,三种不同的意外。把它们当成事件来数,正是它们一直分开的原因。** + **推论:把它们并成一条的动作本身就是发现,而不是整理。** +- **一条没有重开条件的押后,两天后和「遗漏」长得一模一样。** + (readme-cn,2026-09-05,给 §2.12 补的。) + 他们给「§2.8 措辞该不该改」的押后加了触发条件——**「当这个问题不再挂着一个 + 待定的计数时」**——而不是一个日期。**和这份文件拒绝无限定的「永远」是同一条理由。** +- **一次 PASS 确立的是「validator 被调用了」,不是「它看到了什么」。** + (m1,2026-09-05,今天最该往前排的一句,而且它反了我下的筛查方向。) + 同一个「零文件 materials」的故障**也会产生通过**,而且是在他们当作对照的那次运行里: + ``` + w17m1e zone …773bf177 materials 0 个文件 args [] 无报告 verdict: true + p6m1 签名完全相同 materials 33 个文件 args [] 无报告 verdict: true + w17m1g check_profiling_evidence 0 个文件 → 拒绝 + ``` + **故障是非确定性的、与 validator 无关的;它可见与否只取决于落在谁身上:** + 落在严格的上 → 一次读起来像生产者缺陷的**误拒**; + 落在宽松的上 → **在空目录上通过,彻底不可见。** + > **我的判据「引用了文件内部数字的拒绝证明它读过文件」只扫得了拒绝。 + > 一次通过不附带任何理由,所以那个判据对它根本无法施加。** + **唯一的事后判别是 zone 的 `materials/` 文件数**——工具: + `bash assets/lib/refusal_saw_something.sh `(头部写的是 `sh`, + 但第 39 行用了 `set -o pipefail`,dash 跑不了)。 +- **撤回不是安全动作,它是另一种出错方式。**(m1,2026-09-05,自己举证,而我也上当了。) + 他们先把 apply 那件事说强了,再撤回说弱了,**而第二版是错的**。 + 他们的自诊:*「我是在一个**呈现信号**上撤回的——那行有 `NOTE` 前缀、还写明了自己的 + 后果,于是我把标签当成了关于严重性的证据。我读的是输出怎么被标注的,不是那个函数。」* + **和「读 phase 日志而不是 transcript」同类:更便宜的产物在手边、说得通、于是停下。** + > **今天我一直把「往回收」当成安全的一步——它不安全,它只是换一个方向错。** + *而我采纳了那次撤回并把它归档,正因为撤回看起来安全。 + 「检查主张,不论它指向哪个方向」这条,我们两个都没有用在自己的更正上。* +- **第三种后果,而它是最坏的:同一个故障会杀掉一次正在正确进行的运行。** + (2026-09-05 23:16:58,`r5m1b`,我发现、m1 独立复核。**这条要排在下面那条前面, + 因为前两种败坏我们「相信什么」,这一种毁掉正在正确进行的工作。**) + + | 它落在哪 | 结果 | 可见吗 | + |---|---|---| + | 严格的 validator | 读起来像生产者缺陷的误拒 | 可见,而且误导 | + | 宽松的 | 在空目录上通过 | **不可见** | + | **严格的,而且在活跃工作的上游** | **杀掉一次健康的运行** | 只在那一行 `done` 里 | + + ``` + verdict check_profiling_evidence: FAIL ← zone 被递了 0 个文件 + handoff integration_report slot v0: generating ← 阶段 5 正在写报告 + done main 在等一个没有人会做的决定 …… 900 秒没有变化 + ``` + **因果链四步,没有一步提到那个其实完好的产物**:空目录 → validator 无物可读 → + escalation 没有接收方 → 900 秒计时器。**而失败的那个阶段是 mock 的,根本不在被测之列。** + **`integration_report slot v0: generating` 是定案的那一行**(m1 找出来的): + 阶段 5 不只是在跑,**它正在写报告**。 + > **发生率约「11–13 个 zone 里 1 个」——高到「任何一次运行的绿都该查而不是假定」, + > 又低到「重跑不构成诊断」。** + **解释它的那一行不是最后一行**:底部 `run complete; the run did NOT finish` 读起来 + 像普通收尾,原因在它上面 108 行。**今天第二次「最后一行是错的那一行」。** +- **框架会把一个**零文件**的目录送进验证区,而那份拒绝报告读起来完全像一份对产物的判决。** + **(标题原为「一个空的 `v0`」——错的,见下方更正。)** + (m1,2026-09-05,用受控对照证实,今天最坏的一次仪器失效。) + ``` + w17m1e PASS materials/dc334dbd…/v1 45 files + w17m1g REFUSE materials/0704b267…/v0 0 files + handoff 0704b267: v0 = 0 个文件,v1 = 47 个 + ``` + **同一个包、同一份语料、同一个 validator、同一个 kind**,只有两个 m2 的 merge + 不读的变量不同。`check_profiling_evidence` 给出八条 `PROBLEM:`、点名八个缺失文件—— + **而那八个文件在拒绝发生的那一刻全都在封存内容里。** + > **报告格式良好,每一句都是它被指向的那个目录的真话。verdict、报告、升级信息里 + > 没有任何一处提到 `v0`。** + **今天第三个 `v0` 事件,而这一个是框架的**(前两个是 m5 的推断和 m4 的工具, + 都能在自己代码里修)。**body 无法防御「被递了错的目录」。** + **更正(m1,2026-09-05 20:3x,推翻的是我这条记录的标题和我发给所有人的那条命令): + `v0` 不是缺陷。** 在同一次通过的运行里,`check_acceptance` 是从 **`v0` 且有 97 个文件** + 被 stage 的,而 `profiling_evidence` 是从 `v1`。**`v0` 是一个正常的版本,可以是满的。** + > **缺陷是「被递了一个零文件的目录,而同一个 handoff 的另一个版本有 47 个」—— + > 和版本号无关。判据是文件数,版本号是噪音。** + *照我原话去查的人,会去找「materials 里有 v0」,到处都能找到,然后什么都得不出。* + **而推翻它的证据二十分钟前就在 readme-cn 的表里**——`integration_report v0=6(唯一目录) + → 指向 v0,6 个文件,有内容`——**我读过那张表,没有把那一行和我的标题连起来。** + 又一次「答案已经被取回来了,只是没有被读」。 + **判据(可查,而且能反向筛今天所有的拒绝):** + > **一条引用了文件内部数值的拒绝,证明它读过那个文件;一条只说「X 缺失」的,不能。** + 安全的例子:`419218 对 826040`、`expected 4, the manifest lists 2`——都读了内容。 + **而 `check_optimization_shape` 那条给出了可推广的设计要求(m1): + 它报的是 `found: ['...packup_noop_20260905']`——枚举了自己实际看到的东西。** + > **一条枚举实际内容的拒绝,不可能是在看一个空目录,因此对这个故障自免疫。** + **这是对每一个 validator 都便宜的要求,而它会让今天这整场回溯筛查变得不必要。** + **需要复查的:`check_environment` 那三条「no environment.yaml at any of [...]」——纯缺席。** + **识别命令(一条,该贴在每次运行旁边而不是记在谁脑子里):** + ```sh + z=$(find -type d -name 'validation.*output_validation*' | head -1) + find "$z/materials" -maxdepth 2 ; find "$z/materials" -type f | wc -l + ``` + **限定(readme-cn 复查后,而且是对我转述口径的收窄):这对对照证明框架 + 「能」stage 一个空的 `v0`,不证明它在别处也这么做过。** + 他们那次运行里 `deploy_kit`、`operator_workset`、`profiling_evidence` + **都是「`v0` 空、`v1` 有内容」的形状,而且全部通过**—— + **m1 找到的那个形状在这个包里很常见,在这里是良性的。** + 而最强的一个数据点是 `e2e_packup`:**它有两个空的版本目录(`v0` 和 `v1`), + 验证区两个都跳过了,直接指向 `v2`——那不是「`v0` 碰巧有内容」, + 那是版本解析在跨越一个两级的空洞。** + *他们还多走了一步:光看版本只能说明「这次拒绝不是被空目录解释的」, + 不能重新确认那个正面主张。所以他们直接列了**被 stage 的**那棵树, + 三个 kind 都确实没有 `.yaml`——**即便拿到正确的版本,`check_environment` + 照样会拒绝,因为生产者没写。** T76 因此不是「未被推翻」,是「被检验后仍然成立」。* + *m1 还指出它击败了显而易见的下一步:他们第一个假设是 `bench_rounds=1` 改变了 + m2 的产出——合理、可测、而且完全错。**重跑不会推翻它,因为这个故障不由发车行决定。*** +- **一条教训不是因为「没被读」而失效,是因为「你写下它所管辖的那一行时它不在场」。** + (m4,2026-09-05,今天关于「为什么写下来不够」最锋利的一句。) + m5 那条「`v0` 可能是空的」今早就在这个文件里,**而 m4 今天读过这个文件**, + 仍然写下了 `f"v{v['version']}"`——**因为那一刻他脑子里是 validator 的参数。** + > **「读得更仔细」在这里不是一个可用的答案,因为他已经读过了。** + **而他推出的结论比观察更强:** + > **值得带走的版本不是「记住 `v0` 可能是空的」,是 + > 「在信任一件新仪器之前,先跑一个已知答案的案例」。** + **一个机制抓住了他两个缺陷;两条记忆一个也抓不住。** + → **这是「只有第三类会衰减」的最强论据,也是 m2「存放位置」那一轴的延伸: + 不只是教训存在哪里,而是它在它所管辖的那一行是否在场。** +- **一次运行不记录它是用什么 `--var` 发的车——发车行从产物里恢复不出来。** + (m4,2026-09-05,拿 RUN-PLAN 的配方对自己已完成的工作时发现的。) + `p9` 的 `zones/…/package/steps/m2_profiling.yaml` 至今写着 + `expect_ranks: '${expect_ranks:-8}'`——**被 stage 的那份副本没有被渲染。** + 于是「这次运行实际传了 4 还是用了默认 8」无处可查, + 而 `check_trace_coverage` 的裁决恰好是这个值的函数。 + > **一个取决于「运行本身不记录的参数」的判决,是一个薄的判决。** + **今天至少四件事是同一个缺陷:** + - `produced_by.commit = 'unknown'` —— 值是可知的、没人供给,而且没有东西记下「没人供给」; + - `expect_ranks=4` 在那次 78 分钟的 `-noval` 基线里错了一下午,靠打开一个 validator 才现形; + - 2c 那一整类 —— mock 值和真实值,带错方向就产生「像生产者缺陷」的驳回; + - `--var gpu_devices` 连续五次失效,而默认值恰好相同所以查不出来。 + **四件都是「发车行从产物里恢复不出来」。** +- **一个防「未渲染的 `${...}`」的守卫,抓不住「渲染成了错误默认值」。** + (m4,2026-09-05,而且它和今天下午那条是同一机制的两个症状。) + ``` + --var gpu_devices 默认恰好等于所有人都要的 0,1,2,3 → 藏起一个失效的 flag(安静) + expect_ranks 默认 8,而三条运行都是 TP4 → 制造四次驳回(吵闹) + ``` + **同一个机制,相反的症状:默认值等于正确答案时它隐形,不等时它诬告产物。** + → **修法不是加守卫,是从数据本身读:** m4 从每条运行**自己的** `environment.yaml` + 里读 `tp_size`,而不是假设。 +- **一个已知good的对照,抓住了工具自己抓不住的缺陷——而对照是偶然有的。** + (m4,同一份报告,今天关于「先跑一个已知答案」最强的论据。) + 他们的 store 记录写 `version: 0, status: valid`,而 **`v0/content/` 是空目录, + 内容在 `v1/`**。**空目录是格式良好的输入**,所以三个 validator 都正常跑完、 + 写了 verdict、以「文件缺失」驳回——**四条自信的假驳回。** + > **「我的工具里没有任何东西发现它——是它和 m2 已有的 PASS 矛盾才暴露的。」** + **那个对照只是因为我恰好告诉过他 m2 已经判过那一份。没有它,四个假驳回会作为 + 发现被报上来。** + → **建一个新的评分/审计工具时,第一件事是让它评一个你已经知道答案的样本。** + *而这个 `v0` 缺陷今早就已经写在这个文件里了(m5 一小时内撞过两次),它没有到达 + m4——**分发失败,不是警觉失败**,正是 m4 自己四小时前给我的诊断。* +- **缺席比在场被优待——供给那个字段才把你暴露给检查,省掉它买到一次干净通过。** + (m5,2026-09-05,写注释时才想清楚的那句。) + ```python + if not operator_id or op.get("operator_id") == operator_id + ``` + **manifest 没有 `operator_id` → 静默匹配 workset 里第一个算子, + 报告一个「干净的集成点」——一个从没和任何东西核对过的集成点。** + **manifest 有 `operator_id` 而且不一致 → `_DISAGREES`。** + > **这不是「缺席不可见」,是「缺席被奖励」。检查在惩罚完整性。** + *他们自己回放的那份产物就是这么「找到」一个的;m1 的 manifest 恰好带了那个字段, + 是这次不一致能被看见的唯一原因。* + *可存活的细节也记了:`check_apply_manifest` 确实会报这个字段缺失, + 但 `bad` 在这次调用之前算、在之后抛——**所以这一条先跑,读者看到的是它的答案。*** +- **核查自我更正,和核查自我指控,不是一回事。** + (今天两条相反的教训,需要一起读才成立。) + - **m3**:一句自责什么审查都得不到,因为怀疑它看起来像替人开脱——**那是一个洞。** + - **我**:我采纳了 m1 一次**错的撤回**并把它归档,正是因为它是自我更正。 + - **m5**:*「核查同事对自己条目的更正,就是『verify everything』变成『不信任任何人』 + 的地方——而今天之所以成立,靠的正是人们公开地更正自己。」* + **分界线:一次自我更正如果**做出了一个可廉价核查的事实主张**(m1 那次说的是 + 「那行代码是有意的降级」——一次代码阅读),就核它;如果它只是**编辑自己的记录**, + 不要回审。**前者是新主张,后者是收拾自己的账。** +- **不承重的细节,是逃过审查的那一类——而它传播得和承重的一样远。** + (m3,2026-09-05,诊断自己把一个**被撤回**的说法写成了「如你所确立」。) + 他们当时人在场,看着那次撤回。**所以这不是信息过期,是他们在写一段 + 「我的实验证明了什么、没证明什么」的段落时,顺手把一个已撤回的说法升级成了确立。** + 他们自己的判据: + > **该抓住的信号是,我伸手去拿它,是当作**佐证细节**——它在给我的时间线收拾 + > 一个线头,不承担任何重量。而不承重的地方,正是我停止核查的地方。** + **同一个容器,同一个下午,三次误判:我两次从「相邻」推归属(一次卡号、一次时间), + 他们一次从「撤回」推确立。共同形状是——三次在做出的那一刻都不承重。** + → **这是第五种豁免。** 前四种是:印证性的结果、无人反对的说法、转述来的发现、 + 自我指控。**这一种最难防,因为前四种至少还是主张;这一种连主张都不算。** + *m3 的补救形式值得抄:把结论真正依赖的三条第一手证据逐条列出, + **那个容器在那条链里一次都不出现**——于是「它是装饰」这件事本身成了可查的。* +- **一个人主动认领的过错,是今天所有防御里唯一没人去核的一类。** + (m3,2026-09-05,他们自己撤回了自己的自我指控。) + 他们告诉我那个 inert flag 影响了自己五次发车、「错的答案和对的答案是同一个数字」。 + 后来一查:**`GPUS=` 在整个 `assets/` 只出现一次,有默认、无人设置, + 而他们自己的阶段读的是 `E2E_MEASURE_GPU`——那个机制从来不经过他们的代码。** + 他们把同事的机制套到了自己的代码上,没有先查自己的代码读不读那个字段。 + **他们自己的诊断比这更尖:** + > **我主动认领了这个过错,这让它更不容易被质疑,而不是更容易。** + **这是这套体系的一个洞。** 我们已经会核「印证性的结果」「无人反对的说法」 + 「转述来的发现」——**而一句自责什么审查都得不到,因为怀疑它看起来像在替人开脱。 + 我当时也没核,正是这个原因。** + + **m3 的补充,说明这一类为什么更顽固:这个豁免是社交性的,不是认知性的。** + 印证性的结果、无人反对的说法,是**注意力**没到那儿而漏掉的; + **一句自责漏掉,是因为核它看起来像不厚道——审查者必须主动去反驳一个人对 + 自己的指控。** 前者靠提醒能修,后者要付人情成本。 + + **收口(m3,2026-09-05 傍晚,把这一条和它的反面接上了): + 「主动交出对自己不利的那一半」正是今天能成事的机制——而它恰好也是 + 唯一没人去核的那一类。** 原话: + > **它依赖的是「在没人会去查的时候,主动说出更弱的那个说法」。而我今天在 + > 反方向上犯过一次:我主动认领了 `gpu_devices` 那个 flag 的过错,而它其实 + > 不适用于我的阶段——它没有被审视,恰恰因为它对我不利。** + > **所以这个习惯有用,但它不能替代那次核查。** + **同一个习惯,既是这套协作可信的原因,也是唯一一类不被核查的主张的来源。** +- **一次升级里捆着两个主张——机制和时钟——它们可以可靠性不同,却以同样的自信抵达。** + (m3,同一小时,自己举证。)机制是对的(`reset_gpus.sh` 确实是节点级), + **时钟是错的**:把 `mock_stages=m2,m3,m4` 读成前段全 mock,于是「几分钟内」, + 实际是 35 分钟。**两个主张一起送到,语气一样。** + *如果他们肯自己对别人的线动手,就会杀掉一条还有半小时余量的运行。* + **推论:一封「快来不及了」的升级,机制和时限要分开标注置信度; + 收到的人先核时限——因为它决定你还有没有时间去核机制。** +- **「从没开始」的检查,覆盖不了「已经做完并停住」。**(m2,2026-09-05, + 为今天最贵的那个缺陷设计探针时说的。) + m4 的判据是「`forge/` 会不会在五分钟内出现」——它抓一个**从没启动**的阶段。 + 而赔掉 hold `112699` 的是一个**做完了**的阶段:`forge/` 在、phase 行写着 + `running`、节点安静——**这三条对「一个正在思考的健康 agent」同样成立。** + **分开它们的是 transcript**:干活的 agent 持续写,做完的戛然而止。 + 这就是 `ep_poll`、17 个空闲线程那个尸检的直接签名,不需要任何推断。 + 阈值 7 分钟,对照当天观察到的最长健康间隔约 4 分钟。 + **而 m2 主动写明它不做什么,这才是可信的原因:它只探测,不修复。** + 一旦响了,阶段已经完成而运行仍不会前进,**唯一已知的补救是拆除,而拆除会丢掉 + 该阶段下游的一切——所以一次命中是「去把 agent 的报告捞出来」的指令,不是恢复。** +- **同名不同根的树:第三个实例,而且这次两棵都是真的。**(m3 的归类。) + 前两次是「一个名字指向空」和「一个名字指向两个标题」; + **这次那个名字指向一棵活着的树——只是不是那棵活着的树。** + `/home/yihou/e2e-flow-noval-093` vs + `/home/yihou/dev/git/…/e2e-flow-noval-093`,后者才是 093 链 stage 的那棵。 + **我按名字相似挑了测试目标,`rc=0`,差一条消息就去告诉别人那个守卫失效了。** + *而那棵 16:08 建的多余树删除被权限拒绝,所以它会以诱饵的形式活过今天。* +- **一句汇总数字扫一眼无法证伪,一张表可以——所以交表,不要交那句话。** + (m1,2026-09-05,自纠一个说过两遍的数,而且赶在它进 `WHAT-GREEN-ESTABLISHES` 之前。) + 他们说「21 个 kind 里 18 个完全严格」。**21 是 validator 数,不是 kind 数; + 实际是 15 个 kind 里 12 个。** 两个数被混成一个,然后被复述。 + **「18 of 21」读起来完全合理,而且没有任何东西可供核对;那张十五行的表格 + 每一行都能被反驳。** + 和「计数要分 kind 引用」同族,但更进一步:**问题不是那个数错了,是那个形式 + 不邀请核对。** 他们自己的话:*「我宁可交给你一张表,因为我搞错的正是那句话。」* + **推论:凡是要进文档的汇总数字,连同它的分解一起交。** +- **一个状态字段说的是「当前状态为什么成立」,不是「接下来会怎样」。** + (m3 给的形式,而当天下午我就被它打脸,证据是第一手的。) + 我从 `112799 PENDING (QOSGrpNodeLimit)` 推出「217 一掉,112799 就起来」, + 并把这个预期发给了两个人(标注过「报实际不报预期」,但推理还是发出去了)。 + **实际发生的:217 的 hold 16:01 过期,`112799` 至今仍 PENDING, + 而一个全新的 `112862` 在 16:01:33 提交、16:01:34 启动,拿走了那个槽。** + **`QOSGrpNodeLimit` 告诉你它为什么在排队,不告诉你约束解除时会发生什么。** + 同族:`squeue` 的 `TIME_LEFT` 是**上界不是时刻表**——我预测那堵墙预测了一小时。 +- **第六种「卡是空的」:占用量的是**现在**,而一条活着的链占的是**将来**。** + (2026-09-05 15:58,m1 报「093 完全空闲——八张卡,还有 6h20」,而**同一时刻 + 两条全真链正跑在 093 上**。) + 我去 `rocm-smi` 核了:**cards 0-3 VRAM 0%。m1 的测量完全正确。** + 但那两条链一条在 `build_workset`、一条在 `identify`——**都是 CPU 阶段。 + 卡此刻真的空,而它们回到 GPU 阶段时就要用。** + **和前五种都不同**:声明/意图/读数过期/孤儿容器/活 agent,那五种里读数本身是错的 + 或会过期。**这一种读数对、而且还会对一阵子——错的是从「空」推出「可用」。** + > **判据:先问「这台机器上有没有活着的链」,再问「卡现在忙不忙」。 + > 两个都要问,而第一个先问。** `lines.sh` 回答第一个,`rocm-smi` 回答第二个。 + + **为什么第一个在前(m3,比我那版准):不是「进程表更好」,而是 + **问题 1 能否决,问题 2 不能**。而且两者互不包含,所以谁都不能省:** + - **进程表看不见**「没有编排的容器」——活过自己 run 的 agent、重启的孤儿,今天都实测到过; + - **读卡看不见**「已发车但还没 bring-up 的链」,也**完全看不见一个 CPU 阶段**。 + + **修正(m3,2026-09-05 傍晚,他们自己降级了自己那条):发车前读卡不是保护, + 是对其他租户的礼貌。** 它今天**两次「正确且无用」**——16:03 在 217 读到八张 + baseline,`glm53-work` 16:40 才来;14:59 在 237 读到八张 baseline,一个容器 + 60 秒后重新加载。**两次真正保住运行的都是 `deploy.sh` 的前置检查:它在 + bring-up 那一刻读,并且带着数字拒绝。** + > **它告诉你「你会不会撞到别人」,不告诉你「你的运行能不能活下来」。** + **问题 1 原样保留**——进程表能否决,而且任何代码级前置检查都替代不了它, + 因为它回答的是**别人的意图**,不是硬件的状态。 + *我原来把问题 2 写成了保障,那对它太客气了。* + + m3 拒绝了我给的台阶(「这条你本来也抓不到」),理由值得留着: + ***工具我有。`lines.sh` 回答的就是问题 1,而我今天早些时候把它当占用检查用过—— + 正是它被改名要防的那个错。所以第六种不是够不着,是我把进程表归类成了读卡的备份, + 而不是前置问题。*** + *代价:差点把 093 的卡派给 m3 的 NCCL 实验,而那台机器上跑着今天推进最远的两条链。* +- **为了让比较可做而自己挑的那个边界,必须和结果一起说出来。** + (m4,2026-09-05,一次调查里同形犯了两次,而且**第一次是他们自己命名的**。) + - `find -maxdepth 4`,文件在深度 7 → 「原生输出不存在」。 + - 剥掉 399 字符的 header 去比 body → 「header 声称的出处,body 没有」。 + **而那段 header 写到第 13 行**,第 8–13 行正好说明这是三段式合成文件。 + **两次都不是仪器骗人,是我自己设了一道界,然后报告界内的结果、不报告那道界。** + 「剥掉 399 字符后 body 大了 5.2 KB」这句话是真的,**它同时静默断言了 + 「header 是 399 字符」**——而那一句才是错的。 + **可机械化的形式:凡是报告一次带截断/深度/范围的比较,把那个界写进结论那句话里。** + m4 自己的注脚最狠:**第一次他一小时前就诊断并命名了,命名没能阻止他在同一次 + 调查里重犯——这正是「只有第三类会衰减」,而且是一条关于第一类规则的第三类规则。** +- **「先别提交」不是一道闸门——包 stage 的是工作树,不是 HEAD。** + (m4,2026-09-05,他们纠正的是我下的指令。) + 我说「你先做,落地由我定」,而我心里的「落地」是 `git commit`。 + **一次运行在每个任务开始时 stage 工作树,所以一处未提交的改动会到达此后开始的 + 每一个任务,全程没有 commit 参与。** 照我的字面执行,就是先改树、再等一个 + 已经迟到的裁决。他们把补丁放在 `/tmp` 里——**那是在纠正指令,不是在服从它。** + **闸门必须落在「改」之前,不是「提交」之前。** + *和「杀编排不等于停运行」同族:我以为的那个控制点,不是系统真正的控制点。* + **换掉那句话本身(m4:「落地」对人是 commit,对框架是 edit)—— + 不要说「先别提交,落地我来定」,要说「在我点头之前把它放在树外」。** + **补丁待命的位置写成 `/tmp/…`,不是工作树里的一个分支或一份 `.orig`。** + *下一个被告知「你先做,落地我定」的人,会照字面执行。* +- **一个全绿的 premise gate 保护不了一次比较,因为决定答案的那个变量不在它的字段里。** + (m4 的 agent,2026-09-05,而**造成它的是我**。) + 它比的是 `gpu_arch / gpu_count / tp_size / dtype` —— 四个静态字段,全绿。 + **而决定这次结论的是「机器忙不忙」,gate 看不见。** 同期 stock 重测比记录时慢 + **7.1%**,因为相邻四张卡上有另一条线在跑。 + **那条线是 m1 的,在 217 的 4-7 上,而「一台机器两条线」是我按规矩 5 安排的。** + → 规矩 5 没错,但它有一个**可测量的代价,而且恰好落在要出数字的阶段上**。 + **凡是产出「测量」的阶段,前置条件里必须有一条「节点安静」,而 premise gate + 给不了它。** 这是 2026-09-02 遗留的那个未决问题第三次独立复现。 + **同族:`_agree_or_die` 覆盖 28 个字段里的 3 个。守卫覆盖的是有人想到的字段。** +- **它拒绝声称加速,而这是今天最该被学走的一次「产出」。** 同一个 agent: + 对 workset 记录的 baseline 是 **0.9883**(慢 1.2%);对同期 stock 是 **1.0587**, + 与 forge 自报的 1.0748 互相印证。**两个读数都远低于 workset 声明的 + `noise_floor: 1.1729`,于是它整块删掉了 `claim`,两张测量表都留着。** + 它自己的话:*「我删的是断言,不是数字。」* + **对照本文件里那些「值的形状 vs 值的意义」的教训:这是同一枚硬币的正面—— + 数字齐全、门槛在手、结论是「不声称」。** +- **两份拷贝大小不同,可能是量具的单位不同,而不是数据缺了。**(m2,2026-09-05, + 抢救 217 时。)`du` 报 215 M / 186 M,差 30 M,看起来像漏拷。**文件数两边都是 + 12,857,一个不差。** 差在 12k 个小文件在本地盘和 NFS 上的块分配不同—— + **`du` 量的是块,不是数据。** + **判据是文件数,不是 `du`。** 和本节其余各条同形:仪器没坏,它在回答另一个问题。 + *他们的 `RESCUE.txt` 还把 `GENERATED.txt` 那条教训搬了过来:`cp -a` 保留源 mtime, + 所以整棵副本里每个文件的时间戳都描述原件,**唯一描述「拷贝这个动作」的就是那一行 + 读出来的 `rescued_at`。*** +- **一个「曾经为真」的值,可以经由**指令**抵达,而不只是经由工具或记忆。** 我让 + m4 把 217 上测的 13.3% 写进 287 的产物。工具和记忆都有防御(重测、重读、查 + 分母),**协调者的指令没有——它抵达时已自带授权**。owner 必须像核查工具一样 + 核查交给他的指令。 +- **一次笼统的暂停,对下令的人是免费的,代价落在那些本不需要它的条目上——而两边 + 都不会察觉,因为什么都没有失败。**(m3,2026-09-05,他们把它认成和今天一整天 + 在抓的守卫同一个形状:**一个保守的默认值,在它错的时候是沉默的。**) + 我对 m3 说了四次「等树安静」。三件配得上——都改变行为,而包按任务重新 stage。 + **第四件是一段 markdown,我默认扔进了同一个桶。** 说出口的时候没有成本, + 被押后的恰好是等待期间最值得动的那些。 + m3 自己的补刀:*「你纠正它是有用的那一半;我那半只是没有开口问。」* + **推论:凡是给出一条笼统的限制,同时说出它不适用于什么。** 未加限定的限制会 + 一直扩张到没人反对为止,而**「没人反对」在今天这份文件里已经等于「没人检查」**。 +- **一个读数可以在被取的那一刻正确、在被用的那一刻错误——而 git 里也一样。** + 2026-09-05 巡检:`git status` 报 `M kernel_taxonomy.yaml`,二十秒后 `git diff` + 为空。我推成「写过又还原」并写进了给用户的汇报。**真相是 m3 在这中间提交了 + `f69ae83`——文件没变,HEAD 变了。** + 我这一轮**开头就跑过 `git log`**,它当时完全正确,二十秒后过期。 + **和「卡在被读时是空的」是同一个形状,只是从集群搬到了仓库里。** + 判据是廉价的:`M` 后面跟一个空 diff 有两个解释(还原 / 提交), + **区分它们的是一次 `git log`,而且必须在观察之后取,不是之前。** + + **两条的公共形式(m3,而且它同时管住上面那条卡):把读取移到落子点,不是决策点。** + 「发车前重读卡」和「观察后再取 log」是同一条指令。**两者都不封闭那个窗口, + 但都把它缩到你真正控制得住的最小一段。** + m3 的对照也值得留着:*我这条便宜,只因为判据存在而且是一条命令;他们 14:59 那次 + 读卡没有等价物——这正是第五种失效至今未被覆盖的原因。* +- **三个人对一个 flag「意图」的一致,不等于一次对它「效果」的测量。** + `graph_ceiling.py:169` 的注释记录了三条佐证——m1 的注释、m5 读 `start_worker.sh`、 + 两个源文件。**三条都是「这个 flag 应该是什么意思」的阅读,没有一条观察过引擎。** + 而 m4 的 kit 同时传 `--cuda-graph-backend-decode full`,新配置路径在 decode 上 + 胜出,**那个 flag 被静默推翻**——于是这个判据恰好在「decode 图真的在跑」的运行上弃权。 + **来源独立不等于方法独立。** 三个人读同一类证据,只是三次同样的阅读。 + **tier 2 形式:一条断言「某 flag 有何效果」的注释,必须引用一次观察(运行、日志), + 不能只引用源码阅读——「它引的是哪一类」是可查的。** + **2026-09-05 我自己犯了同一条,而且是在庆祝一个「对照」的时候。** 我说 + 「工作集里写的 `base_sha256` 和我 `docker run … sha256sum` 量的一致,是两个 + 独立来源吻合」。m3 纠正:**只有当他们的生产者是「量」而不是「抄」时才独立—— + 它确实是量的,但 `identify` 的 `image_facts` 跑的也是镜像里的 `sha256sum`。** + 所以那是**同一个方法的两次独立执行,不是两个方法**。 + **它能抓住「抄来的值」和「过期的值」;抓不住「两边都哈希了错的文件」。** + 推论:说「两个来源吻合」之前,先问**它们用的是不是同一个方法**。 +- **一致性检查通过时,先问它比的两个值是从哪来的。**(readme-cn,2026-09-05, + 给 `_agree_or_die` 写的判据,但形状是通用的。) + > 两个**独立**来源 → 这次通过是有信息的。 + > **一个来源被拆成两份** → 它只告诉你复制成功了。 + 三条把一个来源拆成两份的路径,今天各出现一次:**继承**(下游全部 + `env_render --inherit` 同一份 kit)、**铸造**(记录在源头就写错)、**自引用** + (我把 `premise.run_environment` 设成 workset 自己的环境)。 + **这是「所有比较方都共享的错误看不见」那条规则的极限情形——「所有方」退化成一方。** + 通过仍然返回 true,但它**排除不掉任何一种世界状态**。 +- **观察者会写进它所观察的产物里。** m2 用 `worker.log` 的 mtime 当活性信号, + 而他自己每次 `health_generate` 探测都会在里面写一行「Health check failed」—— + **mtime 追踪的是轮询器,不是引擎。** 引擎最后一次真实工作停在 09:47:39, + 而 mtime 一直在动。**修法是 tier 1:这个轮询只读 `up=` 和 `traces=`,不读 mtime。** + 一般形式:**一个会写进产物的探测,使那个产物不能再当活性信号用。** +- **今天两个人各自靠「和已知矛盾」抓住了一件坏掉的仪器,而两个人都说了同一句: + 那不是一道防线。**(m4 与 m5,2026-09-05。) + - **m4**:`v0/content/` 是空的,三个 validator 正常跑完并以「缺文件」驳回—— + **四条自信的假驳回**。*「我的工具里没有任何东西发现它——是它和 m2 已有的 + PASS 矛盾才暴露的。」* + - **m5**:`$R` 是 zsh 变量,在 `bash -c` 里被写成 `\$R`,bash 展开了一个未设的名字, + 于是每条路径都不存在——**一个来自不存在路径的干净的零**,而且方向恰好相反 + (报的是「两个 IP 都零命中」)。*「只因为它和我几分钟前读过的一份 + `deployment.json` 矛盾才被抓住——这是今天唯一起过作用的防线, + 而它根本不是一道防线。」* + **这一条本来就在下面。今天的新东西是:两个人独立撞上它,两个人都自己指出 + 「靠矛盾抓住」不可依赖,而两次的对照都是偶然存在的。** + → **可依赖的那一版是 m4 给的:先让新仪器去评一个你已经知道答案的样本。** +- **坏掉的仪器,只有在它和你已知的事情矛盾时才会被抓住。** m3 抓住 `find` 的空 + 结果,不是因为谨慎,是因为两条命令之前他刚 `ls` 过那个目录。**如果那条命令是 + 他跑的第一条,他会信。这种情况至今没有任何防御。** 推论:一个工具给出的 + **第一个**结果,和一个**确证**你已有判断的结果,是最不该信的两种。 +- **派活时明说「如果那行是工具的假象,就停下来告诉我」。** 这一句比工具本身值钱: + 它给了人「交回空结果」的许可,四个 owner 因此没有改动任何一个 validator。 +- **通过的那一侧往往更危险。** 一个被剥掉阈值的 validator 是**平凡通过**的, + 所以 26 个 PASS 是最弱的证据,不是最强的。 + **同一形状会出现在审计上**:一个只做正向遍历的检查,面对「缺席型」缺陷不是 + 沉默,而是**报告文件健全**——十三行准确、零分歧、结果干净,而要紧的四行 + **从来不在被检查的总体里**。 +- **一个结果如果印证了你此刻正在支持的东西,你去核它的概率会下降。** 三个实例、 + 两个人、同一方向:checkpoint 的「连续七个生产者都选了 8」讨好了他正在审视的 + 改动;他的「这个检查在首次应用时抓住了我」讨好了他正在背书的检查;**而我把第二条 + 放大给全队和用户,讨好了我刚采纳的机制——我没有核它。** + **操作准则:印证性的结果要按驳斥性结果的标准去核,尤其当它印证的是你刚写下的东西。** + **推广(checkpoint 08:20):不只是「印证性的」,是「无人反对的」。** 他往 bug + 记录里写了九个「最后核对」时间戳,**一个都不是读出来的**——07:40 看了一次钟, + 之后全靠外推,最离谱的一个**快 66 分钟、落在未来**。而他是**在同一个文件的 + 页眉里采纳「凡会过期的都要标日期」这条约定时**写下它们的。 + 他自己的诊断:*「我全程手边就有一只钟,一条 `date -u` 的距离,而我没跑, + 因为没有任何东西在质疑那个数。」* **无人质疑的说法,享有和印证性说法同样的豁免。** + 补上的那一条:**日期本身必须是读出来的;一个编造的时间戳比没有时间戳更糟, + 因为它让「已核对」看起来是可查的。** + +- **共存租户会污染一次失败,不会污染一次成功——所以判读必须是不对称的。** + (m3,2026-09-06 04:4x,`t551` 落到他们正在用的四张卡上之后。) + 观测量是 `ncclCommInitRank` 完不完成,不是跑多快: + > **3/3 通过 → 比率成立,共存租户反而是加分;任何一次失败 → 那次试验不可判读, + > 我会明说,而不是把它记到镜像头上。** + **理由是那次失败会多出一个活的第三解释,而那正是 237 留下的处境: + 一次无法归因的失败。** + *第二重守卫不需要他们争取,是机制给的:`deploy.sh` 的前置检查有每卡 200 GiB 下限。 + 实测 37 GB 已用、约 250 GB 空闲,通过;**若扫描涨过约 88 GB/卡,下一次试验会在 + bring-up 之前大声拒绝**——今天 16:05 就这么拒过一次。**是机制在决定,不是人。*** +- **一条会走到真实阶段 5 的链,在有外来负载的节点上,必须把「停在阶段 2」当成安全 + 要求而不是排程便利。** `reset_gpus.sh` 是节点级 `kill -9`,只保护 `slurmstepd`。 + **区别在于:「我本来打算停在阶段 2」会在一次快跑完、下一步看起来很便宜的时候消失; + 「停在阶段 2 是安全要求」不会。** 这正是 15:07 m5 的 agent 拒绝执行它的那个情形。 + +- **一个哨兵值恰好越过了守卫,而差额是一个字符。**(m3,2026-09-06 05:0x, + 修 `produced_by.commit` 时顺手发现的。) + schema 对该字段有 `minLength: 7`,**而 `unknown` 正好是 7 个字符**。 + > **守卫是为了挡住「太短的值」而设的,而那个占位符按一个字符的余量通过了它。** + 和「值的形状 vs 值的意义」同族:**长度约束挡不住一个长度合法的谎。** +- **用「重新序列化」去改一个共享文件,会顺手改掉别人的行——语义相同,diff 不同。** + (同上,m3 自己捕获并回滚。)他们第一版用 + `json.dumps(..., ensure_ascii=False)` 写回 schema,**把别人描述里三处 + `\u2014` 转义静默变成了字面 em-dash**:JSON 语义完全相同,**三行不属于自己的 diff, + 在一个四个人都在编辑的文件里。** + `git checkout` 回滚后改成定点文本编辑,最终 diff 2+/1−(那个 1 是一个逗号)。 + > **这就是「重建 vs 追加」那条的又一实例,而他们是在对别的每件事都很小心的时候撞上的。** + +- **三种豁免可以同时出现在一句话里,而那正是最危险的形状。** + (m3 的归纳,2026-09-06 05:1x,归纳的是我。) + 我给 `buckets:` 设的条件是「第一条绿链之后」,m3 问今晚算不算,而我的回答是: + > **「条件是我设的,东西是我想要的,而开口是你给我的。」** + **印证性的答案 + 无人反对的来源 + 决定者本人受益——今晚一直在分别记录的三种豁免, + 同时成立。** 诚实的读数是:最好的一块板是 16/17,带一个结构性拒绝, + 而 `packup` 每一轮都没达到——**没有任何一条链走完五个阶段。所以答案是不。** + *而 m3 拒绝把「解除了一个条件」当成「解除了另一个条件」,是这件事被摆到台面上的原因: + 我把三项一起说成解封,而 `buckets:` 从来不是卡在树上。* + +## 零和缺席 + +- **一个由「偶然撞见的实例」算出来的发生率,分母从没被数过——它不能用来推翻测量。** + (m2,2026-09-06 00:1x,而且这是他们**对自己结果**提的告诫,不是对别人的。) + 「11–13 个 zone 命中 1 个」来自三个**偶然**发现的实例。m2 采了 **80 个 staged + handoff / 60 个 zone,零个空**,两件独立仪器一致。 + **诚实的读法不是「mock 路径看不见它」,而是「那个发生率本身不可靠」**—— + 因为它的分母从来没有人数过。**「先测分母」在这里换了个位置:要测的是那个 + 被用来做对照的估计值的分母。** + + **而真正值钱的不是那个零,是「前提很常见、而且每次都被正确处理」:** + ``` + 多版本、且存在一个空版本 → staged v1,非空 20 次 + 单版本、没有空版本 → staged v0,非空 60 次 + ``` + **100 个 handoff 里 30 个带着真实实例那个 `v0=0, v1=N` 的形状,被 stage 的 20 个 + 全部选中了有内容的那个版本。** 于是最自然的那个假设死了——**不是「空 `v0` 让 + staging 犯迷糊」,那种情形一直在发生而且处理正确。** + *这同时用 80 个新样本独立印证了 m1 对 `v0` 说法的推翻:60 个 zone 从 `v0` stage, + 个个有内容。* +- **一个「零」到底是「没记录」还是「没发生」,不跑控制组分不出来。** + 踩过三次:`zone: NONE`(成功的任务也是 NONE,该字段根本不存)、 + `ps --ppid` 无子进程(几十分钟后才查,子进程早退了)、 + `ws_handoff_refine` 零写入(目录是只读挂载,再忙也是零)。 + **先测分母,再报分子。** +- **但分母只解决「空间」歧义,解决不了「时间」歧义。** 2026-09-05:节点上零容器, + 可能是「从未启动」也可能是「已经跑完并自行拆除」——**这两者是同一个零**。 + checkpoint 拿另一台节点做对照,正确地排除了「exec 命名空间假象」,但**任何跨 + 节点对照都区分不了这两个,因为差别在时间里,不在读数里**。而我把「没有进程」 + 推成「body 从未启动」,实际是它在 08:25:48 就自行拆除了,checkpoint 08:27 才看。 + **解决它的是一个带活动时间戳的产物**(`deploy.log` 里的 `started_at`)—— + **要「活动」的证据,不要「状态」的证据。** +- **一次成功的重试会毁掉自己的证据。**「重试之后就好了」和「它本来就没坏」, + 事后是同一个观察。*代价:我 07:05 撞上 spur 控制平面失败,重试通了,记成 + 「暂时性」;09:45 同一个错误杀死了验收链——**第一个数据点在我手上两个半小时, + 被我当成了噪声。**「第二个实例把它从偶发变成一种条件」(m2)。* + **这条是可机械化的(tier 2):重试必须留下被它跨过的那次失败的产物。** + m1 的 NCCL 重试已经这么做了——它保留 `worker.attempt1-nccl-fail.log`, + 所以那四次故障是可数的;我的 `squeue` 重试什么都没留,所以第一次不可数。 + **但这个先例只覆盖三条通道里的一条(checkpoint):** `78909fc` 改的是 + `readme.md`——**是 brief 不是代码,所以它只到达 `kind: ai` 那条路径**; + 程序路径是空白;**而丢掉第一个实例的那次重试,既不是 ai 任务也不是程序任务, + 是一个操作者在敲命令——没有 brief 到得了那里,也没有代码路径能强制它。** + 「先例已经在包里」不等于「问题解决了」——**真正失败的那个案例,恰好在先例 + 够不到的那条通道上。**(这是「brief 无法指示程序」的推广:**它也无法指示操作者。**) +- **崩溃会挡在真实驳回前面。**「崩了」不等于「否则会通过」——10 行崩溃里有 4 行 + 修好后照样驳回。 +- **「暴露」不等于「原因」。** 数「有多少行含模板」是暴露面,数「有多少行崩在 + 模板上」才是原因。 + +## 读产物,不读退出码 + +- **`SAVE_OK` 只是退出码**;拿 `docker load` 去验才知道 tar 完整。 +- **失败命令的输出会被写进产物字段。** `engine_argv.txt` 里存着 + `Error response from daemon: ...`,而三个 validator 全部通过—— + **有内容的错误文件比空文件更难发现。** +- **`| head` / `| tail` 会吞掉 rc。** 今天吞过四次,其中一次差点让我给正确的 + 代码报 bug。用 `PIPESTATUS` 或分开取。 +- **agent 的 transcript 是一件我们谁都没用过的仪器。** m4 那条线「停滞 64 分钟」, + 被诊断成挂死、被拿去质疑停滞检测器、被我建议去查 `wchan`/`stack`——**而 agent + 早就结束了这一轮并写下了完整的拒绝理由,在 transcript 里躺了 64 分钟**:启动行 + 少了 `--var gpu=`,`HIP_VISIBLE_DEVICES` 为空,而包里三处写明「body 不许自己 + 改它」,**它认出自己就是那个 body,所以拒绝挑一张看起来空闲的卡**。 + **mtime 和进程表回答「它在动吗」;transcript 回答「它在做什么、为什么停」。** +- **`tail -1` 也会吞掉内容,而这一次更贵。** 我的十分钟巡检一整天都在跑 + `grep -aE 'phase|did NOT finish' $f | tail -1`。四条「死因不明」的运行,**真正的 + 死因就写在最后一行的上面一行**——`"the escalation reached the top … Nothing has + changed for 900 s"`——**而且它含 `phase`,我的 grep 匹配到了它,然后 `tail -1` + 把它扔了。** 日志在 `/home/yihou/` 里可读了一整天;没有任何东西不可恢复, + **我们只是从没读过最后一行之前的那一行。** 取 `tail -3`,或者分开抓终止行。 + +## 不要从名字推断 + +- **容器名前缀不等于归属。** 我从 `yihou_e2e_flow_*` 推出「m1 在调试」,报给用户 + 两次,实际是 run 自己的 agent。**判据是 FATAL 之后 5 秒就出现的容器,不是前缀。** +- **删掉一行之前,先读它上面那段注释。** 那条 `froms` 边看起来是多余的排序边, + 注释说明它保护着下游唯一可信的吞吐数字,而且删错了**不会有任何报错**。 + **把它当成候选之前也要读。** m2 花了一次双臂对比去确立「`/stop_profile` 的 + `ReadTimeout` 是症状不是故障」——**而调用点上方十二行的注释早就写着**: + *「路由的 HTTP 客户端有 30 秒读超时……这里失败不构成『停止失败』的证据—— + 下面那个字节数检查才是判据。」* 作者预见到了它,并为此建了那个检查。 + **他的对比确认了一个已文档化的设计决定,而不是发现了什么。** + +## 环境 + +- **算「还剩多久」。** `squeue` 的 `%M` 是已用,上限 8 小时。 +- **`/shared_nfs` 在登录节点是只读**(`ro,relatime`),计算节点可写。 + 从登录节点 `mkdir` 会静默失败——不要把 `2>/dev/null` 加在会失败的命令上。 +- **zsh 不对未加引号的变量做词分割**,`--include=*.json` 会被 glob 掉,要加引号。 +- **`--stall-after` 是双刃**:默认 20 秒让「升级无接收方」几秒内就报出来; + 我设成 3600 把同一条诊断藏了一小时。**900 秒是折中**——够一次冷启动(实测 + 222/232 秒),又不至于把失败藏起来。 + +--- + +# Task — Handoff refine: chain the five LLM e2e optimisation stages into one graph + +Build **one** task package, +`agent_sys/examples/llm_e2e_performance_optimization/e2e-flow/`, whose graph runs +stage 1 → 5 in a **single `agent-sys run`**, to the rules in the repo-root +`mission.md` (rewritten 2026-09-03). + +Three deliverables, in order: + +1. **Definitions rewritten** to mission.md's general rules + per-module list. +2. **Mock e2e green** — mock agents fed by the 25 real sealed handoffs in + `/shared_nfs/yihou/agent_sys/cheat_for_mock/`; one run walks all five stages + and every validator passes. +3. **Real e2e** — staged, one stage promoted from mock to real at a time. + +Decided with the user 2026-09-03: **new package, the five demos stay untouched**; +**all three items this round**; **team = 5 module owners + 1 checkpoint writer** +after a solo contract freeze. + +## Background + +The five stages used to be five *separate* packages, each driven to a real +cluster run on 2026-09-02. They were not a flow: **a handoff only travels inside +one run's graph**, so five packages are five runs and nothing chains. `e2e-flow/` +is the single package that joins them. + +**This is a refine of definitions, not a rewrite of bodies.** The ~20k lines of +`.py`/`.sh` assets carried over are the only thing here that has ever produced a +number; they move and adapt, they do not get re-derived. + +### 进度(2026-09-05 11:5x,读表得来,不是外推) + +| 阶段 | 状态 | +|---|---| +| ① 造 handoff + 重构 validator | 完成 | +| ② 单独并行跑每个 mock | 完成 | +| ③ 串通 mock | **完成** — 3 m 46 s 走完五阶段,15 个 handoff,`run complete`(validator 关);开回 20 个后阶段 1、2 通过 | +| ④ 单独跑每个真实 | m1 ✅ m2 ✅ m3 ✅ / **m4 首次跑起 campaign 未完成** / **m5 从未跑过** | +| ⑤ 串通整个真实 | 最远 = mock 1–2 + 真实 3 完成 + 真实 4 进行中 | + +**这张表会过期。改它之前先读运行日志,不要照抄。** + +## Context — this environment (rows carry their own measurement date) + +| | | +|---|---| +| where I am | login node, **no GPU**, no direct docker daemon | +| GPU holds | **Do not read a hold or a node IP from this file — run `squeue -u $USER` and resolve the IP from the node itself.** Holds cap at 8 h; every row here expires faster than the file is re-read. *2026-09-05: the two jobs this row used to name (`106250`/`crsuse2-m2m-061`, `106253`/`crsuse2-m2m-031`) were both long gone, and m1 took `10.245.159.129` from here and nearly pointed a real deploy at another tenant's host. "measured 2026-09-03" was honest labelling and did not prevent it.* | +| reaching them | `spur exec bash -c '...'` — exec namespace; docker talks to the **host** daemon, but the **filesystem identity is you, not root** (measured 2026-09-04: `id -u` → `50112975`, writes land `-rw-r--r-- yihou ubuntu`). Matters because `/home` is `sec=sys` NFS, where a root-squashed write would map to `nobody` and leave a tree nobody can clean up. | +| shared FS | `/shared_nfs` 360 T, **46 T free**, shared by every spur node — this is how "remote" works | +| scratch | `/shared_nfs/yihou/agent_sys/ws_handoff_refine/` — **writable from a compute node only.** `/shared_nfs` is `ro,relatime` on the login node, so this path is not scratch for the leader; see the debugging section. | +| mock inputs | `/shared_nfs/yihou/agent_sys/cheat_for_mock/` — 25 real sealed handoffs, one folder per kind, **sealed 2026-09-02 and therefore older than this round's contract**: zero `environment.yaml` in 442 files, and the `operator_workset` has no `workset.yaml`. Do not delete it and do not hand-write the missing parts — graft today's real artefacts (see rule 10). Patched copy: `/home/yihou/cheat_for_mock.20260905T115337/`, originals kept beside each graft as `content.pre-0905`. | +| fast loop | `python3 -m agent_sys.cli.main show --package --var …` loads and type-checks every yaml in **< 1 s** | +| **4-minute loop** | **the whole five-stage graph runs on the login node with no GPU in under 4 minutes** — `--package e2e-flow-noval --var mock_stages=all` plus `MOCK_IMAGE_ID=sha256:…` as an **environment variable** (without it `mock_adapt.sh` cannot read the digest, exits 3, and the task is recorded `succeeded` anyway). Measured 2026-09-05: 3 m 46 s, 15 sealed handoffs, `run complete`. Every question that used to cost a GPU hold and forty minutes is asked here now. | + +## Key references + +- **`mission.md`** (repo root) — the authority. Every requirement below traces to + a numbered item there. +- `/shared_nfs/yihou/agent_sys/cheat_for_mock/README.md` — **four things that + will mislead you**, including a `kernel_table` that is a 34-row synthetic seed + and an `integration_report` carrying a *refused* verdict. +- `/shared_nfs/yihou/agent_sys/debugging/integration/DELIVERY-NOTE-FROM-LEADER.md` + — why that refusal was the validator working, and why the 5 % / 10 % bars must + **not** be widened. +- `agent_sys/spec_loader/validate.py:34-56` — the `jsonschema` idiom to copy. +- `e2e-flow/RUN-PLAN.md` — **the canonical launch block. Open it FIRST, then diff + your line against it.** Everyone who did it in the reverse order paid: it is the + only place `--timeout 21600` appears, and `--var transport=spur` was missing + from it until 2026-09-05 and cost m3 a run two hours in. +- `e2e-flow/WHAT-GREEN-ESTABLISHES.md` — which validators have ever refused + anything, and what a PASS from each one is worth. + +## Core principles + +1. **Read the artefact, not the exit code.** Every acceptance claim names a file + to open and a condition that fails. +2. **`items_schema` is not a schema layer.** Measured: for a file/tree item + `handoff/content.py:184-197` validates the *filename string*, never the + contents, and the schema is never exported to a body. Mission rule G2 needs + real schemas under `assets/schemas/`, loaded by producer **and** validator. +3. **Every identifier bound on a shared host is a parameter.** Container names, + ports, workdir, served model name. `: "${VAR:=…}"`, never `export VAR=`. +4. **Deletion — see standing rule 3 at the top of this file; that is the rule.** + It is wider than the version that stood here until 2026-09-05: **every path on + every host**, not just `/shared_nfs`, and `/tmp` is an allowed substring + alongside `yihou`. `CONTRACT.md` §5.2 agrees. + *This entry used to carry the pre-widening wording and therefore **permitted + deletions the current rule forbids** — under `/home`, `/mnt/m2m_nobackup`, and + inside container mounts. Found by readme-cn auditing this file 2026-09-05.* + **Container removal is standing rule 1, not this entry**: `docker stop -t 10`, + never `rm -f`, and "you did not create it" is **not** a reason to leave a + foreign GPU user running — rule 1 retired that caution. + Never `agent-sys run --clean` on a shared root — it removes *every* run. +5. **Research → gather → analyse → plan → work.** The repo receives only + `e2e-flow/` and `todo.md`. + **Scratch is `/home/yihou/`, not `/shared_nfs`.** `/shared_nfs` is + `ro,relatime` on the login node (measured), so anything the leader writes goes + under `/home/yihou/` — which also satisfies the deletion rule's `yihou` + substring. +6. Bugs in `agent_sys` are recorded under + `agent_sys/examples/llm_e2e_performance_optimization/temp/bugs/` first, then + worked around; fixed only when the evidence is unambiguous. + **`temp/` is gitignored while all its records are tracked** — a narrow + `git add -f ` is house style, not an override. +7. Work in English; report to the user in Chinese. + +## Other notable details + +### Two mission requirements that read as traps + +- **Rule 7, "all tasks share one docker container".** Module 5 needs **two** by + construction — a container holds one state for its life, which is the whole + reason the two-arm design exists. G5.1's *"如果不行,再考虑…启动不同的 docker + container"* grants the exception: modules 1–4 share one, module 5 brings up + its own two arms from the same image and the same `environment` record. +- **M5.1.3, Python runtime hijack.** M5.3 says *"首先记入 todo … 但现在就这样吧"* + — record the disagreement in `todo.md`, **keep `overlay_files`**. + +### DCO sign-off is required on every commit + +CI blocks any PR containing a commit without a `Signed-off-by:` trailer. + +```bash +git commit -s -m "..." +git config user.name && git config user.email +``` + +Sign off **as yourself** — never a colleague's line, never a bot identity. + +### Branch / PR + +Branch `dev.yihou.aiopt.task_package.concat`. Activity is limited to +`agent_sys/examples/llm_e2e_performance_optimization/` plus the root-level +`*.md` notes this effort writes. diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 08297ff5b..49d4058cb 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -1,68 +1,2234 @@ -# Repository conventions +# 用户的规矩 —— 读任何别的东西之前先读这里 -## DCO sign-off is required on every commit +> 用户 2026-09-05:*「拜托你把我说的话都沉淀到 CLAUDE.md 的最前面,每次都忘。」* +> 下面每一条都是用户亲口说过、而且**我至少忘过一次**的。忘记的代价写在每条后面, +> 那不是修辞,是当天的账。 -This repository enforces the [Developer Certificate of Origin](https://developercertificate.org/). -CI blocks any PR containing a commit without a `Signed-off-by:` trailer, so an -unsigned commit is a broken PR, not a style nit. +> **两个集群并行,2026-09-06。** 下面「用户的规矩」和累积的调试教训是共用的; +> **本次(第二个集群、MI355X 本机)的任务与本机事实,见文件末尾的'# Task — THIS cluster'一节。** +> **读机制,不读数值** —— 另一个集群的节点、jobid、`/shared_nfs` 路径在这里都不成立。 -Commit with `-s`, always: -```bash -git commit -s -m "..." -git commit -s -F - # when writing a longer message from a heredoc +## 集群 + +1. **我们持有的所有机器上,凡是不是我们和系统的 GPU 占用,一律杀掉,然后并行安排 + 工作。** 用户 2026-09-05 放宽:**不分 QoS**,`amd-primus-q` 同样适用。 + (最初只说 burst-qos,同日放宽到全部持有的机器。) + 我方 = 带 `infera_e2e_run` 标签;系统 = 集群监控; + 其余全杀,**不论它跑了多久、不论谁还持有那台机器的配额**。 + **只杀 GPU 占用**——不占卡的 CPU 容器不动。 + 用 `docker stop -t 10`,**永远不要 `rm -f`**。 + **这条覆盖「你没创建的容器不归你删」和「两个持有者时归属不可判定」。** + + **前提(m5,2026-09-05 20:0x,量出了反事实):这条规矩假设占用者是静态的。** + `ajafari` 的 `t551` 是一次**多节点扫描,每个上下文长度起一个容器**。 + m5 停了 `t551-w3-10M`——**而那次扫描在 19:47 自己清空了,19:57 又回来。 + 那一停赔掉了别人一个扫描点,什么也没买到。** + > **对着一个会重生的作业,停它既不能腾出节点,也不会让共存租户完好—— + > 那不是执行规矩,那是在打一场对抗。** + **判据:如果占用者会重生,停止不是执行——该上报。** + *今晚我据此在 093 和 237 各裁定过一次「不去争」;m5 给的是它的一般形式, + 而且带着反事实的测量。* + *代价:我拿后两条当挡箭牌拖了四轮,217 在别人 34% 的作业下空转数小时。* + +2. **不允许申请机器,只允许查询和使用。** 不发 `sbatch` / `salloc`。 + 机器不够就说不够,不要自己解决。 + +3. **删除铁律:路径不含 `yihou` 或 `/tmp` 子串的东西,一律不删。** + 宿主机上如此,docker 挂进宿主机的目录里同样如此。 + +4. **算力要算「还剩多久」,不是「已用多久」。** 作业上限 8 小时,`squeue` 的 `%M` + 是已用。发一条线之前先算剩余时间够不够这条线跑完。 + *代价:昨天四个 hold 死在 8 小时整点的半途。* + +## 怎么用算力 + +5. **所有任务一律 TP4,不要占满八卡。** 一台 8 卡机同时跑两条线(0–3 / 4–7)。 + + **全真链必须取 0–3,不能取「哪半空就哪半」**(m2,2026-09-05,由 `GPUS=` 那条推出来的, + 我没推出来):`mix_worker.sh:26` 的 `GPUS` 有默认、无人设置,**所以 m5 的臂永远吃 + `0..TP-1`**。**把链跑在 4–7 上,会让阶段 5 落在和阶段 1–4 不同的卡上。** + + **例外(2026-09-05 16:12 实测,m3 发现、我读代码确认):任一半跑真实 m5 时, + 这个分法不成立。** `mix_up.sh:81` 调 `reset_gpus.sh`,后者 `:37` 遍历 + **整个节点**的 KFD 进程、`:48` 对 comm 匹配引擎名的一律 `kill -9`; + **只保护 `slurmstepd`,不保护另一条线的引擎。`--var gpu_devices` 不约束它。** + 还有第二重:`kv-events:5557` / `kv-snapshot:8801` 在 `mix_up.sh:70` 硬编码, + **两个 m5 阶段无论传什么端口都不能共存于一台节点**;而 `GPUS=` 在整个 + `assets/` 只出现一次(`mix_worker.sh:26`,有默认、无人设置),**所以 m5 的臂 + 永远吃 `0..TP-1`**。 + → **凡是 `mock_stages` 里不含 `m5` 的线,那台节点必须独占。** + *代价:我按这条规矩把 217 劈成两半,而 m1 的线会在约 16:44 杀掉 m3 的引擎—— + 那正是今天等了一整天才拿到的匹配 digest 节点上的实验。* + +6. **跨节点先试。** 失败了分析失败原因,不行再回退到同一节点。不要预先把它当路障。 + +## 本轮的目标与顺序 + +7. **本轮不关注端到端是否有真实性能提升,跑通即可。** + +8. **五个阶段,按这个顺序:** + ① 造一份新的 handoff + 重构一遍所有 validator + ② 单独并行跑每个 mock,清掉参差 + ③ 串通 mock + ④ 单独跑每个真实 + ⑤ 串通整个真实 + 两条线之间不断调试,按需修改 handoff(定义和造出来的实例)与 validator。 + +9. **e2e 调通阶段可以先把 task 从 program 改成 ai**,让 agent 吸收 handoff 里的 + 细微差异,避免不必要的失败——**前提是把 markdown 写明白**。 + (`agent/runner.py:801`:环境变量无法指示 agent,只有 brief 能。) + *代价:2026-09-05 我为了把 mock 回路压到 4 分钟,把五个阶段全部强制成 + `m*_agent=runner`(program),**正好和这条相反**,而且没说。* + +10. **造出来的 handoff,在评估影响不大时可以手工微调**,不必为此重跑真实负载。 + 用户 2026-09-05 追加:*「太久的错的语料不是我喂给你的,发现了顺手删掉就可以。」* + **但「微调」不等于「编造」**:语料 workset 连 `workset.yaml` 都没有,而 evidence + 是它里面的字段、validator 会交叉核对 measured spread——补它就是造测量数字。 + **诚实的做法是拿今天真机产出的同类产物去嫁接**,不是手写。 + *代价:我两次把语料缺口当成不可动的边界,直到用户第三次点破。* + +10a. **单独调试某个阶段时,不要求它产出真实的优化/提升。** 用户 2026-09-05: + *「m4 单独调试时不要求跑真实优化……怎么舍弃优化提升尽快拿到结果。」* + **可达性是目标,数字不是。** 降级产物必须自己声明降级。 + +10b. **下游阶段不许等上游跑完。** 用户 2026-09-05:*「m5 根本不需要等 m4 出来 + 再说。」* 把上游算子加一两行无关代码就是一份合法的**反向优化**产物—— + 空改动测出来的就是 baseline,所以 `speedup: 1.0` 是**正确值而不是猜测**。 + **m1/m2/m3 都真实跑通之后,下游没有任何「外界信息不足」的借口。** + 实例:`/home/yihou/make_reverse_kernel_opt.py`。 + +## Validator:先全关跑通,再二分打开 + +> 用户 2026-09-05,原话:*「每个节点真连调试的首个阶段,你可以把 validator 全部 +> disable 掉。着重跑通,然后拿着一份结果,逐个修 validator,这时只需要跑一遍 e2e +> 就能拿到更多的信息。validator 打开的过程中,也可以分阶段二分的打开,比如先打开 +> 稳定能 pass 的,再逐渐收敛到难 debug 的。过于难调的,单独记录文件上报给我就行, +> 真实跑通前不要把太多精力花在 validator 上。」* + +10c. **每个节点第一次真连,validator 全关。** 机制是 + `assets/lib/make_debug_package.py --out /e2e-flow-noval`,生成一棵副本。 + - `validators: []` **非法**——框架原话 *"A kind with no validator cannot be + admitted"*,所以注入 `check_nothing`(全 true、一个不读、往 stderr 打 + `validation is DISABLED for this run`、`strength: weak`)。 + - **必须生成在仓库内**:`env_mgr.workspace.cut` 要求包在带 + `extensions.preciousObjects` 的 git 仓库里。`e2e-flow-noval*/` 已 gitignore。 + - 回收用 `--keep a,b`,并且**保留 `check_nothing` 在每个 kind 上**,这样部分 + 恢复的树仍然自报降级。 + - **`check_deploy_serves` 不能进这个回路**——它做真实 bring-up + 180 秒压测, + 登录节点上会挂死。先砍它,其余 20 个一起开。 + +10d. **一个 `-noval` 的绿只证明「走通」,不证明任何正确性。** 而且它**分不清 + 「走通」和「越过了若干次静默失败」**——所有 verdict 都是 `check_nothing`。 + 唯一诚实的句子是「链子在关闭验证的情况下走完了五个阶段」。 + + **那么在 `check_nothing` 之下,什么还算证据?**(readme-cn 2026-09-05 的判据, + 今天最有用的一条,而且可计算。) + + > **既不是 verdict,也不是文件数,而是「生产者算出来的、其值依赖于它必须 + > 读过的输入」的内容。一个只有真的读了 A、读了 B、比较了两者才能得到的数, + > 静默失败伪造不出来。一个被拷贝进来的文件什么都不证明。** + + 实测:那次 17/17 的运行 495 个文件里 **165 个与语料逐字节相同(33%)**。 + 但**分 kind 看**才有意义——`deploy_kit` 37 个里 35 个是产出的;而语料中段 + 每个 kind 只有 2–4 个产出,**且恰好由 `mock.sh` 的引号修复加 + `env_render` 的 `environment.yaml` 完全解释**——正是「拷贝完再失败」会留下的东西。 + **所以:计数要分 kind 引用,永远不要引总数。495 这个数字应该停用。** + + 那次运行里**唯一不可伪造的事实,是它唯一说「不」的那个**: + `stock_vs_m2` 读了 m2 的 bench、测了 stock 臂、算出 `-0.278058`。 + **失败之后只做了拷贝的 body 产不出这个数。** + +10e. **过于难调的 validator 单独记录上报,不要在跑通前纠缠。** + +## 提交与算力节省 + +17. **暂时不要 push。**(用户 2026-09-05)本地 `git commit -s` 即可,**不要 + `git push`**。DCO 签名照旧。 + *当时的状态:本地与 origin 齐平,说明有人一直在推,别人的提交也跟着上去了—— + 所以这条必须传到每一个会提交的人,不是只有我遵守。* + +18. **尽量少跑第四阶段(kernel opt),跑通一次就复用结果。**(用户 2026-09-05: + *「单次耗时太长了」*) + **实测支持这条**:一次 campaign 在 `forge_max_hours=1.0` 的预算上跑了 113 分钟 + 还停在准备阶段,三个标记全无、源文件一个没改。 + **复用的机制已经有了,用它,不要重跑:** + - `--var mock_stages=…,m4,…` 让阶段 4 回放已封存的 `kernel_optimization`; + - `--var forge_mock=1` 跳过整个 campaign(仍会真跑 STEP 4/5,分钟级), + 产物三层自声明 mock; + - 现成可复用的产物:`/home/yihou/cheat_for_mock.20260905T115337/stage4-kernel-opt/` + (m3 的算子 + 一行注释,`speedup: 1.0`,schema RC=0,readme-cn 审过)。 + **只有在要回答「阶段 4 真跑会怎样」时才真跑它,而那个问题今天已经答过一次。** + +## 记录 + +11. **框架的 bug,无论修了没修,统一记进 `bug.record..md`。** + (在 `agent_sys/examples/llm_e2e_performance_optimization/` 下。) + +12. **调试过程中每一次 validator 失败都要记下来**,便于后续分析。 + (`validator.failures..md`,同目录。) + +## 工作方式 + +13. **小事别找我确认**,用户不一定一直在。 +14. **工作用英文,向用户汇报用中文。** +15. **算力宁可撞墙也别空着。** 一段跑不完的时间照样有价值——它能提前撞出问题并 + 记录下来。用户 2026-09-05:*「可以发,这样遇到问题可以及时发现记录。」* + +16. **巡检周期 25 分钟**(用户 2026-09-05,由 10 分钟改)。 + + **改周期会改变别的规矩,不能只改数字。** 「首次出现只记录,第二次仍未解决 + 才介入」是按 10 分钟校准的:两次 = 20 分钟。**25 分钟一轮,两次就是 50 分钟。** + 今天有实例说明那太贵:一台卡死的引擎占着四张卡、一条线用错包空转一小时、 + 237 有四张卡闲了 16 分钟。 + 所以按代价分档,而不是一律等第二次: + - **占着 GPU 的问题(卡死、撞车、空闲卡组、hold 快到期)——首次就介入。** + 这类的代价按分钟计,而且是不可回收的。 + - **不占 GPU 的(文档、记录、判断分歧)——维持「首次记录、二次介入」。** + - **每轮必做:`sh assets/lib/lines.sh`**(不要临场拼这个检查,见下方 + 「不要相信工具」),以及 `squeue -u $USER` 算**剩余**时间。 + +--- + +# 调试技巧 —— 每一条都是当天赔出来的 + +> ## 一条线看起来不动时,按这个顺序读(m4,2026-09-05,今天全部代价的总结) +> +> | 读什么 | 回答什么 | +> |---|---| +> | 1. phase 行 | **什么被调度了** —— 不是「在跑」 | +> | 2. run tree 最后一次写入(`find -mmin`) | **有没有事情在发生** | +> | 3. 该阶段「第一件产物」的断言 | **哪一步从没开始** | +> | 4. transcript / `store/event` 的 `attributes.detail` | **为什么停了** | +> +> **顺序是有意义的:每一步只有在前一步答不出来时才付出代价。** + +> **停下来的那一步是哪种「投不出去」,事件类型直接说了,不用打开别的东西** +> (m2,2026-09-05): +> +> | 事件 | 执行体 | 记录 | 为什么投不出去 | +> |---|---|---|---| +> | `escalated` | 程序体 | `2a5b4e8` | 从来没有 agent 可以指示 | +> | `handling_failed` | ai 体 | `f3f8f77` | agent 已完成,`mainloop` 已返回 | +> +> **两者的现象完全一样——任务停在 `running`,没有人再试一次。** +> 而**第四步之前**就能分出是哪一种。 + + +> 而最后一步是唯一能给出「原因」的——`message` 在那些事件上是 `None`,原因只在 +> `detail` 里;`kind: ai` 才有 transcript,**十一个叶子里七个是程序体,没有**。 +> +> **动手之前必须走完这四步。** 我 2026-09-05 只看了进程表就杀了一条线,而它离 +> 阶段 5 只有六分钟——日志里写着,我没读。 + +> **先读这一条,它决定下面每一条能不能生效。** +> +> **一条规则有三种形态,只有第三种会衰减:** +> 1. **改变命令** —— `tail -2` 而不是 `tail -1`;`NOW=$(date -u)` 然后引用它; +> 绝对路径 + 不用 `&&` 跨过会静默失败的步骤;可用的 `pgrep` 模式。 +> 2. **改变产物必须包含什么** —— `runtime.replayed_from` 必须存在,而消费者在它 +> 缺失时拒绝;`--node` 不匹配就 rc=2;**一个负控制必须「弄坏 → 放回 → 通过」**; +> 每条验收主张都要点名一个文件和一个会失败的条件;产物里要有出处小节。 +> **这一类不靠记忆,因为「有没有」是可查的。** +> 3. **要求人记得** —— 「读产物不读退出码」「先测分母」。**只有这一类会衰减。** +> +> **推论(readme-cn):当一条教训无法变成命令时,下一个问题不是「写得更醒目一点」, +> 而是「产物里要有什么,才能让这件事变成可查的?」** +> +> **第四个轴(m2,2026-09-05):同一个事实,存在 bug 记录里是惰性的,存在发车块里是 +> 承重的。** 我把 `GPUS=` 记了三遍(flag 失效、两个 m5 不能共节点、m5 的臂吃 +> `0..TP-1`),**都没推出「全真链必须取 0–3」**;m2 一步推出来了,理由是 +> *「我只是因为正要写一条发车行才推出来的——你是在给 `mix_up.sh` 归档一条性质, +> 我遇到的是『我该敲哪几张卡』。」* +> **所以问题不只是「能不能变成命令」,还有「它存放的位置,离用到它的那一刻有多远」。** +> 这正是前置条件 2b 该在发车路径里、而不是在 bug 记录里的理由—— +> **和谁更用心无关。** 这就是「mock 不许断言生产者 +> 没有的事实」变成 CONTRACT §5.3 加一张表、「缺席不可见」变成第二遍审计的路径。 +> +> *代价:readme-cn 今天第二次撞上「`git commit` 因 pathspec 加倍失败,而 `&& push` +> 照跑」——第一次之后他已经写下了教训。他的精确结论不是「写下来没用」,而是: +> **一条必须在复合命令中途、在时间压力下、在你正想着内容而不是机制的那一刻触发的 +> 规则,形状就是错的。** 修法之所以有效,是因为它取消了那个选择的时刻。* + +## 一次授权不会因为你换了个人说话就失效 —— 协调者制造的撞车 + +(第二集群,2026-09-06 14:08,leader 干的。**当天唯一一次两条链同时起在一个节点上, +而两个 owner 都以为自己拥有活下来的那一条。**) + +我先对 A 说「这条消息之后不必再等我的话,去发」,十分钟后对 B 说「发车归你」。 +**我从没撤销第一条。** 两条授权,一个节点: + +``` +3849649 container=yihou_e2e_chain jobid=29184 无 --timeout 无豁免 → runs/…140819 +3856205 container=yihou_e2e_chain2 jobid=29313 有 --timeout 有豁免 → runs/…140831 +两条端口都是 8101/8102/8103,work_root 也相同,两分钟内各写约 1800 个文件。 ``` -That appends a trailer built from **your own** `user.name` / `user.email`: +**三件事各自值得记:** + +1. **两个 owner 都报了同一个 run 目录。** 一个取「最新的 run 目录」拿到了对方的 + (相差 12 秒),另一个把对方的编排当成自己的父进程。**定案判据不是 pid、不是 + 时间、不是目录新旧,是 `--var` 集合——每条命令行都和它作者自己的发车记录对得上。** + *又一次「从相邻推归属」,而这次两个人同时犯,方向相反。* +2. **发车前的「有没有别人在跑」检查,在被跑的那一刻是对的,在发车的那一刻过期了。** + A 在 14:07:46 确认终结,B 在 14:08:09 起来,A 在 14:08:31 发车——**间隔 23 秒。** + 和读卡、读端口完全同形:**读数为真,要紧的是它的保质期。** + (`pgrep -f 'launch_chain.py'` 还会自匹配,得用不会匹配自己的 `/proc` 扫描。) +3. **响的那个是良性的那个**(checkpoint):**端口撞车会中止并自报家门; + 共享 `work_root` 根本不会中止。** 两个都在场时,只有前者会告诉你。 + +**规矩(给协调者,不是给 owner):** + +> **同一时刻只能有一个具名的发车人。改派的时候,必须在同一句话里撤销上一个人的 +> 授权——「X 发车」不蕴含「Y 别发」,尤其当 Y 收到过「不必再等我的话」。** + +*代价只有五分钟,因为还没有 bring-up。同样的两条线晚十分钟发现, +`reset_gpus.sh` 会让后起来的那条杀掉先起的引擎。* + +## 加一道守卫之前,先读这一条 —— **一个修复正确地针对眼前那次失败,而对下一次是错的** + +(第二集群,2026-09-06。**当天最后两次运行死亡,都是由「为修上一次死亡而加的守卫」造成的。**) + +链条是这样的,每一环单独看都无可指摘: ``` -Signed-off-by: Your Name +运行 N 卡被上一阶段的 validator 引擎占着 → 加「等卡释放」 +运行 N+1 等错了对象(等了一个陌生人的卡) → 加「只等我们自己的」的归属判据 +运行 N+2 归属判据判不出来 → UNIDENTIFIED → 立刻中止 → 运行死亡 ``` -**Sign off as yourself.** The DCO is an assertion that *you* have the right to -submit this code, so the trailer must name the person making the commit. Never -copy a colleague's line from an existing commit, and never use a bot or assistant -identity — contributors here sign off under several different addresses, and the -trailer has to match the commit's actual author. +> **每一个修复都正确地描述了它面前那次失败,并且引入了下一次失败。** +> 而第三次的反证**当天早上就已经在这个文件里**(见本节 `VRAMSUM=272 KFD=0` 那条: +> 「我用一个**拒绝不足**的谓词,替换了一个**拒绝过度**的代理量」), +> leader 当天读过它,仍然走了同一条路。 + +**为什么读过也没用:** 那条记录讲的是**某一个谓词**的具体缺陷。这一条讲的是 +**「加守卫」这个动作本身有一个惯性方向**——每一次都在收窄,而收窄的代价 +落在下一次,不落在这一次。**具体实例挡不住一般动作。** + +### 加守卫之前必须回答的三个问题(便宜,而且在动手之前) + +1. **这道守卫在「判不出来」的时候做什么?** + —— 中止、放行、还是等待?**「判不出来」是最常见的分支,而它通常是最后被想到的。** + 写下它,不要让它落在 `else` 里。 +2. **它把哪一类输入从「通过」推到了「拒绝」?那一类里有没有健康的情形?** + —— 上一次的失败在里面;**这一次的成功可能也在里面。** +3. **我是在修一次失败,还是在修一类失败?** + —— 如果只能说出眼前那一次,那么这道守卫的作用域就是那一次, + **而它会一直生效到下一次。** + +### 判据 + +> **一道守卫的价值,不看它挡住了上一次,看它在「它判不出来」时往哪边倒。** +> **倒向放行**:代价是漏掉——可见、可测、可以下次再收。 +> **倒向中止**:代价是一次死亡——而且长得像它本来要防的那个问题。 + +*正面对照(同一天,m35 的 `reset_gpus.sh` 守卫):每一条错误路径都返回 +`unknown:` 并**放行**,而放行落进脚本已有的分支——VRAM 底线超时、 +`mix_up.sh` 在**任何 bring-up 之前**大声中止。**它没有引入新的失败模式, +只是把多一种情况路由进了已有的那一种。** 这是「倒向放行」的形状。* + +*而 m2 的卡前置检查同一天做对了另一半:**无法识别的占用者立即中止而不是落入等待** +——因为在**那个**位置,倒向等待才是危险的方向。**所以这条规矩不是「永远放行」, +是「说出你倒向哪边,并说出为什么那一边在这里更便宜」。*** + +> ### 【2026-09-06 傍晚:上面那个「做对了另一半」的例子被推翻了,而且是同一个人推翻的】 +> +> **「无法识别的占用者立即中止」正是杀死运行 2 的那一条。** 而我在修它的时候 +> 又写了一次同样形状的东西:*「外来容器持有 GPU 就立即中止」*——**而我从没定义 +> 「持有 GPU」是什么意思。** agent 用了最显然的仪器 `HostConfig.Devices`, +> 而本机两个长驻开发容器**永久映射 `/dev/kfd` 且占 0 显存**,于是任何一张卡读到忙, +> 陌生人分支必然触发。运行 6 会死在同一处,只是理由从「不认识」换成了「外来」。 +> +> **两次我都写了一个默认中止的分支,两次那个默认都落在正常情形上。** +> +> **m35 的修法不在分支上,在定义上:** +> *「一个容器只有在显存可以归因于它时才算持有 GPU;把 `/dev/kfd` 映射进容器不是证据。 +> 归因不了就是 `unknown`,而 `unknown` 等待,不中止。」* +> **这就是它同时挡住两次的原因。** +> +> **一般形式(比原来那条强):当你在决定「模棱两可的情形往哪边倒」时, +> 那个模棱两可通常出在一个你没有定义的词上。修分支,下一个读者要重新推导那个定义, +> 而他会挑最显然的仪器——而最显然的那个是错的。定义那个词,才是修复。** +> +> **另一半,同样是 m35 的,而我写不出来:** 光把词定义对了,仍然可能得到一个 +> 「当成罕见分支实现」的版本。他们的第二段点名了 `check_deploy_serves` +> ——*「它在同一组卡上自己起一次机,容器在显存还在排空时就被删掉; +> 预期会遇到一张忙卡而没有任何容器可以归因,约 60 秒。**这是你的前置检查 +> 最可能遇到的情形,也正是那个等待存在的理由。**」* +> **一条指令可以把词定义对,却因为没说清「常见情形是什么」而拿到罕见路径的实现。** + +## 循环的形状,比循环里的努力重要 + +- **一次全链运行只返回一个 bit。** 图停在第一个 invalid handoff,所以串行跑链 + 每次只暴露「下一个错」。*代价:两天,换回一个「rung 1 通过」,而 rung 1 就是 + stage 1 单跑。* +- **先跑最便宜的那个实验。** 同样的信息量:并行探针 3 分钟,串行全链 48 小时。 + mock 在前、GPU 在后;能在登录节点判的,不要占卡。 +- **能并行发现的,不要串行发现。** 阶梯(逐级提升)的唯一作用是可归因,而 + **输入劫持并行注入同样可归因**。`mock_root` + `mock_stages` 就是劫持机制。 + +## 不要相信工具,先证伪它 + +- **我的探针一小时内被 owner 抓出四个缺陷**:空 args(26 行平凡通过)、 + README 取名导致索引到两天前的旧语料、`${var:-default}` 未替换(10 行崩溃被 + 读成驳回)、`overrides` 参数从没被传。**每次都是 owner 在自己的材料上复现后 + 告诉我的。** +- **在多人同时编辑的文件里,永远引用可 grep 的锚点(标题文字、表格首格),不要 + 引用行号。** readme-cn 的 L2133 在「他读到」和「对方读到」之间漂了 31 行,其中 + 20 行是他自己那次提交造成的——**计算时正确,送达时错误,而且是他自己弄错的。** + 今天这个仓库里行号的保质期是个位数分钟。 + **三条轴,而第三条的失败方式不同**(readme-cn):**时间**——裸行号;**空间**—— + `campaign_config.py:316` 不带 checkout 路径;**分配**——从陈旧清单里取的 T 编号。 + **行号会漂移,标识符会撞车。** 漂移的指针误导一个读者然后自我纠正;**撞车的编号 + 把两个发现塞进一个名字下,而且从有人引用它的那一刻起就不再便宜。** + 推论:往只追加的共享文件里追加之前,**在那一刻重读标题清单**,不是每会话一次。 +- **消息可以撤回,提交不会。** 一句关于 `--kernel` 的错误说法在 90 分钟里流通了 + 三手(m4 提出→自己撤回→我转发→m3 写进代码作为修复理由)。**抵达持久化存储的 + 恰好是错的那一个。** 而且它落在最糟的位置:**一个由假理由支撑的正确修复,会被 + 下一个认真的人拆掉**——他去核理由,发现不成立,于是移除守卫。 + **更尖的一版(m3):代码被改回来了,日志没有。** `f92e42b` 的 commit message + 至今带着那句错话且无法修订。**写 commit message 时要当它不可更正——因为它就是。** +- **`ast.parse` 只证明语法,对名字解析一无所知。** m1 用它验了两次并称之为 + 「verified」,而文件里带着一个 `NameError` 躺在共享树里若干分钟。**没有装 + pyflakes 时用 `symtable`。** 一般形式:**仪器无法以你需要的方式失败,而它的 + 输出不会告诉你这一点。** + **`ast.parse` 和 `symtable` 都看不见实参个数错误。** m1 把十二个 `print(...)` + 机械改成 `_note(notes, ...)`,其中两处把 `file=` 带了过去,而 `_note` 不接受它。 + **两处都在 teardown 的 `except` 块里——所以 `TypeError` 只在「teardown 已经失败」 + 时触发,也就是这条 note 最值钱的那一刻。** 这个包在 `NameError` 之后采纳的两个 + 仪器对它都是盲的;**抓住它的是「真的去调用那个函数」。** + **第三种失败方式(checkpoint 09:28):仪器答对了,而你没看。** 他在同一条命令里 + `date -u` 打出 `09:28:36Z`,却手写了 `09:05Z` 进文件——**读取发生了,就在同一行**, + 失败在顺序:他在读取返回之前就把值写死了。**规矩:`NOW=$(date -u …)` 然后引用 + `${NOW}`,永远不要在读取旁边手写那个值。** 前两种是「仪器在回答别的问题」, + 这一种是「仪器在场且未被使用」。 +- **同一条的写法版:`replace()` 匹配不到会安静地成功,`assert needle in s` 让它报错。** + (m2,2026-09-06。)我改 CLAUDE.md 时把缩进写成 4 空格,实际是 2 空格。 + **`s.replace(old, new)` 会返回一个原样的字符串并报告成功**——和 `find` 失败成零 + 同形,只是穿了改文件的衣服。抓住它的是那一行 `assert anchor in s`。 + **凡是「按内容定位再替换」的编辑,先断言定位命中,再替换。** +- **一个从来没有在「它要防的那个配置」里跑过的检查 —— 一个下午三次。** + (m2,2026-09-06。三次都是我自己的守卫,三次都是同一族。) + 1. `--timeout` 只有 `run` 接受,而我的脚本两个模式都传——**于是我今天每一次 + `show` 校验的都是另一条命令行**,那个 flag 从来没在用它的那条路径上跑过; + 2. 那道 abort 的解释从没打印过:`set -u` 先在 `TIMEOUT_ARG` 上炸了。 + **它 fail-closed 是对的,但一道守卫的全部价值就是它那句话;** + 3. `--var` 计数守卫**在结构上看不见任何不是 `--var` 的 flag** —— + 所以它不可能抓到 (1)。 + > **三次都不是「忘了检查」,是「检查存在、但从没在它要防的那个配置下执行过」。** + 判据和上面那条同形:**先用一个你已经知道答案的输入跑它**——包括跑它该失败的那一次。 +- **正面的那一半:一个对坏输入报错、而不是返回空集的工具,让同一个错误代价为零。** + (m5,2026-09-05,今天第三次撞上「复合命令跨过一个 `cd`」——而这次失败得安全。) + 他们的 gate 和 commit 接在一个 `cd` 之后,同时破坏了模块路径和 git pathspec。 + **`git` 把坏的 pathspec 当作错误而不是空集合,于是提交被拒绝,而不是提交了错的东西。** + > **对照本节其余各条:`find` 失败成零、空变量变成全匹配、`command -v` 的错误串非空—— + > 那些工具在坏输入上返回了一个「格式良好的答案」。** + → **选工具、写工具时,优先「坏输入报错」而不是「坏输入返回空」。 + 这是今天唯一一次同类错误没有产生代价,而原因完全在工具的设计里,不在人身上。** +- **错误字符串是真值。** `command -v` 以 `exec: bash: not found` 失败,变量非空, + 于是「这个容器有该程序」。和 `find` 失败成零同形——**只因为答案荒谬才被抓住。** +- **转述时最容易加码的,是别人报告里最谨慎的那一句。** 2026-09-05 我三小时内 + 同形状犯了两次,**两次都是转给同一个人,两次都进了他们的文档**: + + | 同事写的(准确、有边界) | 我转出去的(强了一档) | 一次廉价阅读就能框住它 | + |---|---|---| + | kit 记录 217,而我在 287 上回放它 | 「一份 kit 记录了错误的节点,守卫被反转」 | 读一次 `store/task`:哪次运行产出的 | + | 「这个不一致在 `-noval` 层看不见」 | 「一个**没有任何 validator 在看**的地方」 | 读一次 `check_optimization_shape` | + | 容器 `…r5-09051134` 现在不在节点上 | 「一份 deploy_kit **记录了一个不存在的容器**」 | 读一次那个 run 的 `logs/`:它服务过没有 | + | `apply: NOTE ... The patch is applied on the optimisation's own say-so; M5.1.1 wants...` | 「apply 按优化自己的说法就打了补丁,而 M5.1.1 正是为防这个而存在」 | 读那一行的**前缀** | + + **~~第四种加码方式:把 `NOTE` 前缀剥掉~~ —— 这一条我记错了,而记错的方式 + 正是我当天下午自己立的那条规矩。**(更正:m5,2026-09-05 20:3x。) + m1 说 apply「按优化件自己的说法打了补丁,而 M5.1.1 正是为防这个」, + 然后**自己撤回**,说那行有 `NOTE` 前缀、是有意的降级并声明。**我采纳了那次撤回, + 把它记成一次加码。m5 去读了代码:m1 的第一版更接近对的。** + 调用点的策略是明写的:**缺**集成块 → 一条 note;**有而且不一致** → **硬停**。 + 而实现把两者塌进了同一个分支——`workset_integration` 对四种情况都返回 `({}, why)`, + 三种是缺席,**第四种正是策略说要硬停的那个不一致。** + 于是 manifest 写 `attention_recompute_w_u_fwd`、workset 写 `sampler_vocab_softmax`, + **没有任何东西检查补丁会落在哪里**——拦住它的是 base hash,另一个检查。 + > **所以那不是「有意的降级」,是「一个本该硬停的情形走进了 note 分支」。** + **而我没有核那次撤回,恰恰因为它是自我更正——今天下午我自己写下的第五种豁免: + 一句自责什么审查都得不到,因为怀疑它看起来像在替人开脱。** + *「撤回也可能像主张一样错」这条早就在这份文件里。我今天读过它,并且在半小时后 + 违反了它——正是 m4 那句「一条教训不是因为没被读而失效,是因为你写下它所管辖的 + 那一行时它不在场」。* + + **第三次(16:25),而它换了个失败方式:前两次是把作用域加宽,这一次是把类别 + 弄错。** 底下的事实是**生命周期**(容器真的起过、真的服务过、后来被拆了), + 我说成了**出处**(记录是错的)。**「记录了一个不存在的容器」把责任从消费者 + 挪到了生产者身上,而生产者是清白的。** + 判据便宜到荒唐:那次运行的 `logs/worker.log` 138 KB,里面写着 + `Application startup complete` / `The server is fired up`。 + **抓住它的是 m4,他们拒绝把我的配对写成结论,标为「转述、未核」, + 并点名了那条能定案的阅读——正是我今早写进这张表的那一条。** + **他们拿我的规矩核我的主张,没通过的是我的主张。** + + **但这条记录真正的发现不是「我又错了一次」——是这条规矩第一次自己起了作用。** + (m4 的反驳,我采纳。)*「我点出那次阅读很廉价,恰恰因为今天失手两次的是我; + 而那条规矩之所以在那儿,是你今早撤回之后写下的。**它是按 tier-2 的方式生效的: + 谁都不需要『记得小心』,问题已经写在那里,而且只要一条命令。** + 失手的那个主张恰好是你的,这是关于『下一个主张是谁提的』,不是关于我们两个人。」* + **今天前面每一次同类都是靠某个人恰好谨慎抓住的;这一次是靠写下来的东西抓住的。** + → **一条 tier-2 规则奏效的证据,比又一个失败实例稀有,也更值得记。** + + **两次都不是我没查,是我在「转述」这个动作里跳过了查。** 自己做实验时会核, + 接力时不会——因为报告读起来已经像结论了。 + **规矩(readme-cn 给的机械形式,比我自己那版准):转述一份别人的发现、而且 + 用了比他们更强的作用域之前,跑那一次能证伪「更强版本」的阅读。** + 不是「多查一点」——**是查「加码所依赖的、而原报告没有主张的那件具体事」**。 + 两次都属于同一个窄类,各一条命令: + - 「记录了错节点」← 哪一次运行产出了这份产物?一次 `store/task`。 + - 「没有 validator 在看」← 有没有任何 validator 读这个字段?一次 grep。 +- **镜像:一个被记成「救了我们一次」而其实没救的正确修复,会被下一个人过度信任。** + (m2 与 leader,2026-09-06 晚。)一次误读被归因给「两条链共用 `work_root`」, + 而实际成因是**我在 run tree 里 grep,拿到的是 m1 自己那份 preflight**——`0` 在那里 + 是正确值。**分开 `work_root` 拦不住它:m1 的 preflight 是封进这次运行自己的 handoff 的, + 换成 `e2e_flow7` 之后同一条 grep 返回同一个 `0`。** + > **一个被记上了它没做成的功劳的修复,比一个没被记录的修复更糟—— + > 它在「有人会停止检查」的那一点上买来了虚假的信心。** + **所以记录时要把成因钉在正确的那一条上:** + 「从 grep 里取值而没问是哪个阶段产出的」——分开的树不防它; + 「两次运行的文件在 `pmoff/` 里共存」——真实、不中止、只能事后读产物发现, + 但**和这次误读无关**。 + *和上一条是一对:假理由让正确修复被拆掉,假功劳让正确修复被高估。两者都靠 + 「把理由和功劳分别核对」来防,而不是靠修复本身是否正确。* +- **今天最接近造成真实损害的一次:一个由正确测量支撑的错误修复,拆掉了正在起作用的 + 那个检查。** 2026-09-05,我干的。 + 我造的产物里 `results/optimized_kernel.py` **不是 Python**——它是 workset 的 + `reference.md`,一篇讲「参考实现在哪」的散文,`ast.parse` 直接 SyntaxError。 + 而 `apply_mode: overlay_files` 会拿它去覆盖镜像里一个真实的 Triton kernel。 + **唯一拦住它的是 `base_sha256` 对不上。而我上节点、用真命令、量了一个真哈希填了 + 进去——把那个守卫拆了。** 每一步都站得住,合起来解除了唯一的阻挡。 + **CLAUDE.md 已有的是「假理由支撑的正确修复会被下一个认真的人拆掉」;这条是它更尖 + 的反面。** + m1 抓住它,靠的是他们差点跳过的一步:*「号已经拿到手了,我还是去核了一下这个替换 + 文件是不是从镜像那份派生的——这一步我差点当成官僚流程。」* + **同一小时里我两次把「值的形状」当成「值的意义」**:先是自己写的「必须 64 位十六 + 进制」守卫,被我自己用 52 个零的测试满足并写进活语料;再是这次。 + **两次都是校验了字段,没有校验那个东西。** + **修法不是加守卫,是让整类不可达:** `--base-sha256`(供给)换成 `--base-file` + (从镜像 `cat` 出来),负载 = 该文件 + 注释头,`base_sha256 = sha256(该文件)` + ——**哈希取自负载,所以不可能与之矛盾**。并加了 `ast.parse` 负控制,用当初漏过去的 + 那个文件验过会被拒。 + **推论:一次 schema 检查看不出一个 `.py` 里装的是 markdown。** readme-cn 审过这份 + 产物、RC=0、结论全对——**而这是关于「一次审计买到了什么」最锋利的一句话,它来自 + 一次通过了的审计。** +- **预先登记:在看到结果之前,把「什么算通过」和「现在是什么水平」都写下来。** + (readme-cn,2026-09-05,`68ad295`。今天方法上最硬的一件。) + 他们在 m1 的收敛结果到达**之前**,把 validator 的四个等级连同**可查的判据** + 写进文档,并且**把当前分布也写死**: + + | 等级 | 判据 | + |---|---| + | 1 | 在 mock 数据上 `result: true` | + | 2 | 在**回放**产物上通过(m4 那两个已被永久封顶在这一级) | + | 3 | ② 且它评判的那个 kind,**由本次运行中真正执行过的阶段产出** | + | 4 | ③ **且**在 `3885050` 里拒绝过注入的故障 | + + > **当前:等级 4 为零。等级 3 为零。** + + 于是六个结果到达时,每个 validator 只剩**一个问题**——「它评判的那个 kind + 是不是由真跑过的阶段产出的?」——**这是一次查表,不是一次协商**。 + + **这就是「印证性的结果核得少」那条的 tier-2 解法:** 不是「记得要区分」, + 而是**判据先于数据存在,所以偏差没有可作用的对象**。 + 推论:同时登记「现在是什么水平」,**这样它就不能被悄悄改善**。 + **而它绑住的不只是「结果太差时放宽」,还有「结果太好时收紧」。** + 2026-09-05 结果到达时 readme-cn 写:*「我本可以论证 `check_command_parses` + 只检查一个 `items/command` 脚本能不能解析,拿它当『端到端』太薄。**但判据是在结果 + 到来之前定死的,而在看着第一个数据点时抬高门槛,正是预先登记要防的那件事。** + 如果判据太松,那是另一场对话,不该由第一个数据点来裁。」* + **他们把它标出来,而没有据此行动。** +- **时间戳三条,2026-09-05 各赔过一次:** + 1. **`--time-style=+%H:%M:%S` 是 `ls` 的 `tail -1`。**(m4)它**只打时间、不打日期**, + 所以一个八天前的 mtime 和一个两分钟前的**渲染成一模一样**。m4 因此把 + `2026-08-28 14:44` 读成「今天 14:44」,并据此报了一条「campaign 三小时后还在 + 安装」——**已撤回。凡是这个时间戳要被拿去和别的东西比较,就打日期**: + `--time-style=+'%F %T'` 或 `long-iso`。 + *和 `tail -1` 同形:自己的命令丢掉了要紧的那个字段,而剩下的输出格式完好。 + 区别在于——`tail -1` 是仪器答错了,这个是**仪器精确回答了我问的那个问题**: + 我问「几点」,它给了「几点」。* + 2. **一份拷贝的 mtime 描述的是源,不是拷贝动作。** 今天两个实例: + `cp -a` 保留源 mtime(m4 的 `KernelForge`),`shutil.copytree` 对顶层目录 + `copystat`(我的 `e2e-flow-noval`,和源目录纳秒级相同,m2 差点据此判它陈旧)。 + **想知道「这份副本是什么时候造的」,产物里得自己写一行**——`GENERATED.txt` + 里那个**读出来的**时间戳就是为这个存在的。 + + **2b. 而反过来的那一半更贵:一份「没有重新生成」的副本会静默陈旧。** + (m1,2026-09-05 22:1x,自己举证。)他们的 `r5m1a` 用的包副本 + `generated_at 16:50:32 / source_commit 7e76735`,**而它本该检验的那个修复 + `8f05a94` 是 17:23:21 落地的——晚 33 分钟。** 副本里 `grep -c -- '--environment'` + = **0**,源里 = **4**。于是 `check_environment` 正确地拒绝了 + `integration_report`,**而那是对原始 bug 的正确拒绝,不是对修复的检验**。 + > **「重新生成的树是一件新产物」已经写在这里;它的逆命题才是陷阱—— + > 没有重新生成的树也是一件旧产物,而运行本身不说它跑的是哪份代码。** + **他们差点把它报成「修复没生效」,而判据是一次查表:`GENERATED.txt` 里的 + `source_commit` 就是为此存在的。** 修法是 tier 2 而不是记忆: + `r5m1b` 在 `source_commit ≠ HEAD` 时**直接 abort**,理由写在脚本里—— + *「一份陈旧的副本静默失败:这次运行检验的是你当时有的代码,不是你现在有的代码。」* + 3. **一个落在未来的时间戳,是唯一一种不可能是「读到了旧值」的错。** 它只能是 + 时钟差或读错,所以**它自证有问题,只要有人拿它跟钟比一下**。今天这个判据 + 响了两次:checkpoint 那个快 66 分钟的,和 m4 这个快 47 分钟的。 + **陈旧的值看起来合理、能活下来;未来的值活不下来——前提是有人去比。** +- **一个带默认值的出处字段,记录的是「没人说过」,而不是出处。** + (m3,2026-09-05,举证的是他们自己。) + `scaffold.py:372` → `os.environ.get("E2E_PACKAGE_COMMIT") or "unknown"`, + yaml 里默认成字面量 `unknown`。**今天流通的每一份 workset 都记着 + `produced_by.commit = 'unknown'`**,包括 11:19:40 那份真机产物——它已经嫁接进 + 语料,并被 m4 和 m5 消费。 + **commit 是可知的**(包就是一个 git 树,生产者在里面跑),**但这个字段在等人 + 想起来传一个 `--var`。五次发车,零次。** + 而写下那条 yaml 注释「声明它不解决问题,供给它才解决」的,正是同一个人。 + **这比「缺了就中止」的那类更糟:它不中止,它安静地写下 `unknown`,所以没有任何 + 失败去提示谁。** + 修法是**从树里导出**而不是换个默认值。一个必须记住的注意事项(m3): + 在生产者里 `git rev-parse` 记的是**被 stage 的那份副本**的 commit,而包是按任务 + 重新 stage 的——**所以一条跨越了「代码落地」的链,不同阶段会记下不同的 commit。 + 那大概是对的,而且一定令人意外**,应该写进字段说明里,而不是留给人日后发现。 +- **一份手工订正过的记录,恰好在「有人想到的那些字段」上是对的。** + (m3,2026-09-05,而且这条直接适用于我今天那份语料。) + m5 手改了 `node`、`gpu_devices`、`image_id`,漏了 `node_ip`。数一下就明白为什么: + ``` + environment.schema.json fixed.* 19 个属性(8 个 required)+ runtime.* 9 个 + _agree_or_die 守卫的 3 个:fixed.node / runtime.slurm_jobid / runtime.transport + ``` + **`node_ip` 在 `fixed` 里、不在 required 里、不被守卫。** 唯一留错的那个字段, + 恰好是两边都没有检查的那个。 + **所以正确的结论不是「把其余字段也查一遍」,而是:除了「有人想到」之外, + 唯一的结构性防御是那个一致性检查——而它覆盖 28 个字段里的 3 个。** +- **`gpu_devices` 被发现、`node_ip` 没有,真正的原因是「物理后果」而不是「巧合」。** + (m3 自己推翻了自己一小时前的解释。) + 他们先说是「两个人想要同样的四张卡」。**那是较弱的一半。强的一半是: + `gpu_devices` 有一个第三方会绊倒的物理后果;`node_ip` 只在有东西去寻址错误主机时 + 才有后果,而那时它表现为一个发生在别处的连接错误。** + **巧合让它被发现得「快」;物理性才让它「可被发现」。其余二十五个字段两样都没有。** +- **一个在「输入缺了一半」时保持沉默的守卫,是今天反复出现的那个形状。** + m3 对扩展 `_agree_or_die` 的提醒:它在**不一致**时拒绝,而**环境值缺失被当作一致**。 + 所以给 `node_ip` 加守卫,只在操作者也传了 `--var node_ip` 时才抓得到陈旧记录。 +- **杀掉编排进程不会杀掉它的 `kind: ai` agent,而 agent 会继续创建容器。** + (m5,2026-09-05。今天操作上最危险的一条。) + 实测:14:59 杀编排 + `docker stop` 两个臂容器 → 卡开始释放 → **15:00:09 一个 + `RestartCount=0` 的全新容器出现**(不是 docker 重启,是 agent 新建的)→ 15:08 + cards 0–3 回到 75%。那个 agent 在编排被杀 12 分钟后仍然活着。 + **`docker stop` 一个活 agent 的容器不是拆除,是一个它会撤销的步骤。** + 更糟的是:编排没了,所以这条运行**对任何看 `run_with_long_stall` pid 或 run 日志 + 的巡检都是不可见的**,而它照样占着一个节点的卡。 + > **顺序:先杀 agent,再停容器。** + > 找 agent:`pgrep -f 'claude/versions'`,再 `readlink /proc//cwd` 和 run + > 目录对照——**每个 agent 的命令行长得一样,cwd 是唯一判别器。** + *我今天杀过七个编排进程。查过了:八个活 agent 全部对应五条活着的线,没有孤儿—— + 但那是因为登录节点那几条的 agent 自己退了、088/287 那两条的节点已经没了。 + 换一个时机就不是这个结果。* +- **一个和「你本来会传的值」相同的默认值,会把一个失效的 flag 藏起来。** + (m3,2026-09-05,今天下午最尖的一条。) + 阶段 1 被 mock 时,两条臂从**语料 kit 的环境记录**里取 `fixed.gpu_devices`—— + 那份记录封于 088,写的是 `[0,1,2,3]`——**而 `--var gpu_devices` 根本不进来**。 + **m3 今天五次 mock 发车全都要的是 `0,1,2,3`。所以 flag 在五次里都是失效的, + 而错的答案和对的答案是同一个数字。** + 它不在「你要语料那组卡」时露馅;**它在有人要 4–7 时才露馅——而在双线节点上 + 那是一半的发车。** m5 抽到了另一半。 + + **机制更正(m5,2026-09-05 傍晚,而且是用一次干预测出来的,不是读出来的): + 和 kit 无关。** 他们**把 kit 改成 `[4,5,6,7]`,下一条臂照样去了 0-3。** + 真正的那一行是: + ```sh + assets/serve/mix_worker.sh:26 + GPUS="${GPUS:-$(seq -s, 0 $((TP - 1)))}" + ``` + `grep -rn 'GPUS=' assets/` **只返回这一行,包里没有任何地方设置它**。 + 所以 `TP=4` 时 m5 的臂**永远吃 0,1,2,3**,没有任何 `--var` 到得了。 + **这条比原来那版强,因为它是「改了输入、观察输出没变」——一次干预, + 不是三个人读同一类源码。** 对照本文件「来源独立不等于方法独立」那条。 + *后果:m5 在共享节点上只能是低四张,或者改 `assets/serve/`。* +- **前置检查要数「破坏性那一步真正枚举的东西」,不要数它的代理量。** + (m1,2026-09-06 00:0x,自己的检查拦下自己之后,**改了谓词而不是豁免检查**。) + 他们写的是「节点上零容器」——**那是代理**。`reset_gpus.sh:37` 真正遍历的是 + **持有 `/dev/kfd` 的 pid**。实测: + ``` + 217 上持有 /dev/kfd 的进程:0 + yihou_m3_explore devices=[/dev/kfd /dev/dri] 而 docker top 只有一个 `sleep` + ``` + > **能看见设备不等于持有句柄。** + **数容器会两头错:**把他们挡在一台空节点外去保护一个从不处于风险中的东西; + **而更糟的是,它会在「某个容器持着句柄但 VRAM 读 0%」的节点上放行**—— + 正是真正要防的那一种。 + 改法:~~数破坏性步骤枚举的那个量;容器只作为信息打印,不参与放行。~~ + + > **【45 分钟后被推翻,而推翻它的是 m1 自己在一台已知繁忙的节点上开的那一枪。 + > 上面那条「改法」是错的,照做会造成实际损害。】** + ``` + 00:46:06 VRAMSUM=272 KFD=0 ← 节点确确实实在忙,谓词说零 + ``` + **`spur exec` 的 `/proc` 看不进别的容器,所以 KFD 计数在跨容器时恒为零。 + `docker top` 也救不了——同一次探测对三个容器返回空,而我半小时前用同一条命令 + 拿到了非空:它不是稳定地瞎,它是不稳定,那更糟。** + **真正跨得过命名空间的是 `rocm-smi`**(它读 272 而 `/proc` 读 0), + **所以一直在干活的是「八张卡全零」那道检查,不是新谓词。** + > **m1 自己的诊断,比现象重要:「我用一个**拒绝不足**的谓词,替换了一个 + > **拒绝过度**的代理量,而理由听起来更好——**拒绝不足才是危险的方向**。」** + **这是 `base_sha256` 那个形状同一天第二次:每一步都站得住,合起来拆掉了 + 真正在起作用的那道守卫。** 而 `r5m1c` 发车安全,靠的正是**他们没动过的那一个**。 + **诚实的谓词是两者兼有、缺一不可**:每卡 VRAM(跨命名空间)**加**容器清单 + (覆盖「起机前持有句柄但 VRAM 仍为 0」的窗口),而后者**按「有没有能用 GPU 的 + 容器」放行,不按「我能不能看见它的进程」**。 + *他们拒绝在 00:50 重写它,理由值得抄:同一个文件已经错了两次, + **该上线的版本必须是有人拿一台已知繁忙的节点验过的**,不是推理出来的。* + *另附两条:**一次前置检查无法自己重跑**——`yihou_m3_dev217` 是发车之后才起来的, + 2b 在发车时是满足的;以及那个只报不拦的容器清单顺带暴露了 `charming_yonath`, + 非我方、不占卡,**按「只杀 GPU 占用」不在范围内,没有动它**。* +- **一个针对「你打算用的资源」做的前置检查,查不出「你的意图不会被执行」。** + (同上,m3 推翻了自己一小时前的说法。) + 他们原先说「前置检查抓住了它」。**没有——前置检查抓住的是撞车,不是机制。** + 如果今天分给他们的是 4–7,他们的 `rocm-smi` 会**通过**,因为他们读的是自己 + **要**的那组卡,不是即将真正占用的那组。 + > **那个检查比的是「我的意图」和「世界」;它不会告诉你「我的意图不是软件将要做的事」。** + 推论:**同一份记录还带着 `node`、`node_ip` 和 image。** 卡之所以被发现,只是因为 + 两个人想要同样的四张;其余字段没有这个巧合。 +- **意图不是观察,而清单里两者都要有——它们不能互相替代。**(m3,2026-09-05。) + `--var gpu_devices` 是**意图**,`rocm-smi` 是**观察**。2026-09-05 它们在一台 + 节点上不一致:m5 的线声明 `4,5,6,7`,引擎却坐在 `0,1,2,3` 上,每张 232 GB。 + **而我的 `lines.sh` 读的是声明,我拿它当占用检查用了一下午,并据此告诉别人 + 「哪些卡是空的」。** m3 在发车前跑了 `rocm-smi` 才拦住——否则一台 TP4 引擎会压在 + 今天走得最深的那条阶段-5 链上。 + 改名成 `DECLARES` 只修了标签;**真正保护下一次发车的是「前置清单里两个检查都在, + 而且不互相替代」。** + *我造那个脚本,正是为了取代一个给过四次错答案的占用检查。它回答的是另一个问题。* +- **最贵的一类:没有任何仪器说谎,错的是从它们做出的推断。** + 这一节其余每一条都是「仪器在回答别的问题」——`tail -1`、自匹配的 grep、 + `docker ps` 的 `Up`、空 `logs/`、`find` 失败成零。**下面这两个不是。** + 2026-09-05,同一小时,两个人,同一类的两个方向: + + | | 看到的(都是真的) | 推出的(错) | + |---|---|---| + | m4 | `kernel-agents` 进程活着;`forge_experiments/` 在增长 | 「campaign 在跑」——实际 113 分钟卡在准备阶段,一个源文件都没改 | + | 我 | 进程表显示两条线压在同一组卡上 | 「杀掉后起的那条」——而它离阶段 5 只剩六分钟,日志里写着 | -Check what `-s` will produce before your first commit in a fresh clone or -container, where git may have inherited a default from the environment: + **两个仪器都没坏,换更好的仪器也救不了。** m4 的原话:*「唯一能救的是打开 + 那个目录。」* 我的对应动作是:**动手之前读那条线的日志,不只读进程表。** + **操作准则:当你要基于「它在动/它没在动」采取不可逆动作时,先看它在做什么的 + 那个产物——目录内容、日志、`store/event` 的 `attributes`——再动手。** +- **有些事实,从一次运行内部原理上看不见——判据在运行之外。** + 今天两个实例,形状相同: + 1. **镜像**(m3,237):节点上的 tag 指向 `6440587dc7ba`,而所有 kit 和语料 + 记录的都是 `4601539c0f3d`。**但这条链的 m1 是真跑的,所以它会把 + `6440587dc7ba` 铸进自己的记录,下游每一个检查都会与自己一致。** + 不一致只在**和别的运行比**的时候才显现。 + 2. **节点消失**(m3,088):`store` 里任务仍是 `running`,`ps` 里编排进程仍活着。 + **没有任何产物能区分「running」和「running 在一台已经不存在的机器上」。** + 活动证据是 `squeue -j `,**完全在运行之外**。 + **所以「读产物不读退出码」有个边界:产物只能告诉你它自己知道的事。** + 遇到出处/环境类问题时,先问「这个判据在不在这次运行里」——不在,就别在里面找。 + + **第三个实例,2026-09-06,m2,而它换了个方向:不在运行里的不是环境事实, + 是「这个阶段成功过」这件事本身。** + 我在一棵运行树里诊断了一小时,把 `run_profiling_mode_on` 的引擎冻结逐步收窄到 + `with_stack=1` 的 profiler 窗口,一路都是一手证据(引擎日志少了四行 + `Stop profiling`、生成探测十秒无字节、三个 rank GFX 100 % 带宽 0)。 + **而两个半小时前,同一台机、同一镜像、同一 TP4、同一份 schedule、同样 3 秒的 + `with_stack=1`,成功过一次,`check_trace_coverage` 判了 passed。** + > **一个间歇性故障,在样本量为一的时候,和一个确定性故障长得一模一样—— + > 而「样本量为一」这件事,你在那一棵树里是看不见的。** + 代价:我据此给出的每一条建议(关掉 stack window、三个 `--var`、分离变量的探针) + 都在解一个不存在的确定性问题,并且我两次告诉 leader + 「`check_trace_coverage` 在这个集群从没说过话」——它两个半小时前说过,而且是 passed。 + **判据是四条命令:`find -name capture_stacks.log` 加看最后一行。** + → **在为一次失败命名机制之前,先问「这个阶段在这台机器上成功过没有」, + 并且到运行树外面去问。** 同族:「先测分母」——这里要测的分母是**尝试次数**。 + (m5,2026-09-05 收尾时自己给的诊断,今天最值得带走的一条。) + 他们九个配置没跑到阶段 5,其中**四个死于本可预知的东西**——缺 wrapper、 + `aiperf_trace`、`mock_stages` 没配 `m_agent`、stub 入口。 + **而其中两条是他们自己几小时前写进任务 brief 的。文档没有保护住启动行。** + *同类:`--var transport=spur` 我加进了变量表和一个块,没进那个真被当范本的块, + m3 照抄后两小时才发作。`--var deploy_entrypoint=scripts/stub/deploy.sh` 在封存 + 的 kit 里一直存在,没有任何东西指向它,于是三次四十分钟的等待。* + **推论:凡是「没有默认值、缺了就 abort」的变量,清点一遍,逐个进标准块。** +- **「多核一点」不是修法;「核那个能杀死它的具体问题」才是。而对一条出处主张, + 那个问题永远是同一个:这份产物是哪一次运行产出的?一次 `store/task` 读取。** + (readme-cn 给我 2026-09-05 那次撤回的诊断。) + *代价:我把「kit 记录了错误的节点」放大给全队,二十分钟内进了一个提交。 + **而反证在我自己的记录里躺了几小时**——我当天早上亲眼读过那条命令行 + `pid 2394563 --var node=crsuse2-m2m-217`。它不是「没查」,是**没把已有的事实 + 和这条主张连起来**,因为它印证的正是我早已相信的那个限制。* + **推论:撤回也可能像主张一样错。** readme-cn 在执行我的撤回之前,先自己去 + `store/task` 核了一遍才动手。 +- **一个关于「将来不会」的主张,不能建立在对过去的计数上;计数只许可 + 「至今没有」。**(readme-cn,2026-09-05,自己赔出来的。)他们把 + `check_deploy_serves` 记成「0/55,**永远**不会有理由」——**五小时后 m1 的 + `01ed8dd` 就推翻了它**:12/74,最近八次判定全带报告。**一个已被修复的缺陷, + 被写成了一条不可修复的属性。** 他们随后把 `check_environment` 条目里的「永远」 + 也删了,尽管它的数字仍然是零。 + **推论:改正要落在「读者最先撞上的那一处」**,不只是汇总段——他们自己的发现 + 正是「更正到不了快路径」,所以两处都改了。 +- **重建是对共享文件的破坏性操作,哪怕它里面每一处改动都对。** checkpoint 重新 + 生成失败账本(142+/179−),每一条都是改进,却把 readme-cn 同一小时里的五处 + 更正一起生成掉了——**提交本身无法区分「正确的重建」和「错误的合并」**。 + 同形状:`git commit -- ` 取的是工作树。 + **推论(2026-09-05 修正,原写「一个文件只能有一个 owner」,leader 已推翻): + 需要一个 owner 的是「重建」,不是「追加」。** `todo.md`、`bug.record` 是追加, + 保持共享;账本是重新生成的,归一人。**真正管用的是前置检查而不是归属**—— + 写共享文件前先 `git diff`:**前置检查查的是世界,归属查的是协调者的记忆。** + *代价:归属规则在宣布它的那条消息里就已经是破的——`todo.md` 当时正躺着 m3 的 + 48 行未提交改动。* +- **对别人的运行动手之前,读 transcript,不是读 phase 日志。第二次,同一天,同一个我。** + (2026-09-05 16:42:20,我杀掉 m1 的线,**距离它封存还有三十秒**。) + 我读了编排日志:最后一次写入 16:07:25,只有 phase 行,于是判定 + 「`deploy_and_prove` 在跑,进度正常」。**m1 读 transcript,一遍就拿到真相:** + 16:38–16:40 deploy OK、kit 已写、16:41:29-44 三次 Edit、16:41:53 最后一次节点检查。 + **一个已经完成的 bring-up,agent 正在做最后一步核对。** + 最难看的一句是我自己写在通知里的:*「我读了你的日志,而不是照进程表动手」*—— + **我读了错的那个产物,还把它当成尽到了义务。** + **我考虑过「等它封存再杀」,以「依赖时机的保护是最差的一种」否掉了。** + 抽象地说没错,**在这里错了:transcript 会把它从「依赖时机」变成「依赖状态」—— + 「等封存出现,然后杀」是一个条件,不是一次猜测,而我不知道它有多近,因为我没看。** + 代价:35 分钟完成的工作在入账前三十秒被毁,`--resume` 在半开的 handoff 上死锁, + 只能整段重跑。 + **和 13:11 那次同形、同一个人、同一天第二次。** + → **不可逆动作之前的最后一步,是打开那个能说出「它在做什么」的产物。** +- **我把同一个容器先后判给了两个人,而两个人都说不是自己的。** + (2026-09-05 16:50–17:48,我干的,m1 用**我自己的两条消息**指出来。) + 容器 `yihou_e2e_sgl_serves-dcd949cd`,起于 `16:48:05.667Z`: + - 16:50 我告诉 m3「这是你的引擎」——**判据是 `HIP_VISIBLE_DEVICES=4,5,6,7`, + 那是卡号,不是归属**; + - m3 说不是(他们的运行 16:41:43 就死了); + - 17:5x 我告诉 m1「这是你 `w17m1c` 拆除时漏下的」——**判据是它比他们发车晚 2 秒**。 + **m1 的反证是把我两条消息并排放:「起于 16:48:05.667Z」和「m3 的引擎 16:48:05 + 起来」——同一秒。** 而且机制上不可能是 `w17m1c` 的:那条线活了 110 秒、 + 从没离开 `deploy_and_prove: input_validating`,而 `check_deploy_serves` 在 + **输出**验证阶段才跑,且在 `keep17` 里没有挂载;卡组也不对(它声明 0-3)。 + > **两次都是从「相邻」推「归属」:一次相邻在卡号上,一次相邻在时间上。** + > **而如果 m1 照我说的做了,被停掉的会是 m3 的活引擎——正是前九十分钟 + > 全部用来保护的那个东西。** + **诚实的位置是:我不知道那是谁的,而我告诉过两个人是他们的。** + + **第五个实例,2026-09-06 00:5x,而它是我在把这一条写进文件之后犯的。** + 我从 `yihou_m3_dev217` / `yihou_m3_explore` 的名字推出「m3 的容器」, + 并**在同一分钟里对两个人这样称呼它们**——给 m1 的免停通知,和给 m3 的前瞻警告。 + **m3 用「这个包能创建的完整集合」反驳:** + ``` + identify.py:638 docker run --rm --name yihou_m3_identify_ + mock_adapt.py:338 docker run --rm --name yihou_m3_imagefacts_ + measure_in_container.sh:480 docker run --rm --name $E2E_MEASURE_CONTAINER + ``` + **三个名字,全部 `--rm`、全部带 pid 后缀——而一个 `--rm` 容器不可能作为 + `sleep` 存活。** 加上他们的 hold `112862` 已经超时,**他们此刻根本没有路径上那台节点。** + > **`yihou_` 是我们所有人共用的前缀,`m3_` 被读成了归属。** + **这是今天第四个被按前缀判给 m3 的容器**(此前 `src`/`dev`/`reader` 在 287), + **同一签名:`autoremove=false`、`sleep N`、只有 ubuntu 标签、无 `infera_e2e_run`。 + 同一个未知的创建者,现在出现在第二台节点上——至今无人认领,而且不是我们任何一条流水线。** + *m3 同时更正了自己给 m5 的一句过宽的话:包**确实**会造 `sleep infinity` 容器—— + `mix_up.sh:111`,带 `--device=/dev/kfd`,但那是 m5 的 serve 路径、名字是 `$CTR`、 + 带 `infera_e2e_arm`/`infera_e2e_run` 标签。**正确的判别器是那几个更窄的: + `--rm`、pid 后缀、标签——不是前缀。** + + **同一天第三次,而这次相邻在「样本里恰好共变的那个变量」上**(2026-09-05 20:4x, + T78,m4 推翻)。两条真实链的 `apply_patch` 失败、两条 mock-1-4 的通过,于是我 + 写下轴是「真实 vs 回放」。**同样四行完全符合「算子」这个解释,而算子就在每一份 + 产物里,我从没检验过。** + **更难看的是那个前提:「回放的那份能过」是我自己的一手观察,而它是错的**—— + `133147` 回放同样失败(配对错位,manifest 缺 `operator_id`)。**我把一个自己 + 没查过的对照交给了 m4,还附上「注意 `operator_id` 会静默匹配第一个算子」的提醒 + ——那个字段就躺在我的对照里。** + > **前两次是把「相邻」当「归属」,这一次是把「共变」当「因果」。 + > 判据同形:命名一个轴之前,先问「我的样本里还有什么在跟着变」。** + *而真正关掉这道门的,是 m4 去查了**全部五个**算子而不是失败的那一个—— + 「修那个实例比审计整类更贵」那条的正面实例:五个 baseline 全是 harness 形状, + 于是「换个算子」这条逃生路当场消失。* + + **第四个实例(m5,同一天,而且是这一类最尖的形态):他们从**名字前缀**推出 + `yihou_m3_src` 是 m3 的,把它删了。m3 用 `AutoRemove`、`Created` 和 labels + 证明不是。**而 m5 自己的总结是要害:** + > **我需要的每一个判别器,都在一条我已经跑过的、只读的 `docker inspect` 输出里。** + **不是「没去查」,是「查了、没读」。** 和今天那份躺了一小时的 `ABORT.md`、 + 那份躺了 64 分钟的 transcript、以及 `attributes.detail` 是同一族—— + **答案已经被取回来了,只是没有被读。** + *可用的判据今天已经出现过一次并且是我自己用的:`pgrep -P <编排进程>`—— + 亲子关系。容器侧的对应物是 label 和 `docker inspect` 的挂载,不是时间戳。* +- **`docker ps -a … | head -N` 会把正在运行的容器切掉,而剩下的输出看起来是完整的。** + (m1,2026-09-05;**同一小时我自己也用了 `docker ps --format … | head -4`。**) + 他们的 `head -8` 只显示了 6–19 小时前的容器,于是「节点上没有运行中的容器」—— + **而卡正读着 75%。两个正在运行的排在切口之下。** + 和 `tail -1` 吃掉上一行同族:**自己的命令丢掉了正在问的那个字段, + 而幸存的输出看起来完好。** 判据用 `docker ps -q | wc -l`。 +- **发现一类缺陷之后,修那个实例再重跑,比审计整类更贵。**(m1,2026-09-05,赔了三次发车。) + 他们撞上 `expect_ranks`,**修了那一个变量、重发,然后撞上 `adhoc_cases` 和 + `bench_rounds`**。三个都是同一类:**同一个变量,对被 mock 的阶段是一个值, + 对真跑的阶段是另一个值。** + **修法从来不是「修那个变量」,是「拿整张表逐行对着 mocked 列审一遍」**—— + 他们最后那样做了,一遍就找出剩下两个。**八行里有两行对他们那次是错的, + 而这在第一次发车之前就可知。表本来就是对的,只是没有被当成一张表来读。** + > **把真实值带进一次 mock 的运行,产生的拒绝读起来和生产者缺陷一模一样。** +- **同一个「空值流进破坏性过滤器」的缺陷,同一天出现在两个人各自独立写的脚本里—— + 而第二个被发现,只因为第一个先炸了。**(m1 与 m2,2026-09-05。) + m2 的是 `case "$c" in *"$(basename ${R%/})"*)` → `* *`; + **m1 的 `stop_line.sh` 里,空 `PREFIX` 会让容器过滤变成 `grep '^'`—— + 匹配节点上每一个容器,包括别人的引擎。同样的爆炸半径,不同的路径。** + m1 是拿 m2 那句诊断去对自己的脚本读的:*「我让一个计算出来的值流进了一个 + 破坏性谓词,而没有检查它非空。」* + > **一个类会在同一天独立复现;而它在第二处被发现,靠的是第一处的代价, + > 不是靠更仔细。** + *m1 的加固值得抄:`PREFIX` 必须非空**且**必须含 `yihou`——那正是删除铁律 + 用来判断「是不是我们的」的那个子串;`pid` 必须是数字。四个负控制都跑过, + 不是声明的。* +- **同一套推理,一次得出错误的指控,一次得出正确的提问——差别在于你停在哪里。** + (m1,2026-09-05,自己举证。) + 下午他们从一份 transcript 和一个时间戳构造出「有人扫了 agent」,**指向两位 + 什么都没做的同事**;晚上同样的形状是对的,**而他们把它写成了问题而不是断言。** + 他们自己的话:**「差别不是我的推理,是我这次停在了问句上。」** + *而答案仍然需要 m2 主动报出来——形状对,不等于机制可推。* +- **一个空变量落进通配符,会把过滤器变成全匹配——而这是今天唯一一次伤到别人的错。** + (m2,2026-09-05 19:31:33,他们主动、立刻、带机制地报了出来。) + ``` + R=$(ls -dt .../runs/20260905T193*/ | head -1) # 自己那条是 1929,不匹配,R 为空 + case "$c" in *"$(basename ${R%/})"*) # 变成 * * + kill -TERM 四个 agent # 本意是一个 + ``` + **m1 跑了 22 分钟的 `deploy_and_prove` 和 m5 那条最深的 `integrate_and_verify` + 一起死了**,而后者正是唯一会回答 `check_measurement_order` 的那次运行。 + **他们自己的诊断比 glob 准:「失败不是 glob。是我让一个计算出来的值流进了一个 + 破坏性谓词,而没有检查它非空。`[ -n "$R" ] || exit` 就一行,我没写。」** + > **同一个检查该加在每一个流向破坏性命令的派生值上,不只是那一个变量。** + **而最该留下的是他们对前三次的复盘:那三次只赔了自己的 shell, + 所以这个模式被当成了烦人而不是危险。** + > **便宜的实例会教出关于昂贵实例的错误结论。** +- **「正在收尾」而编排进程还活着的运行,不是在收尾。**(同一小时,m2。) + 他们把一条被自己误杀、显示 `deploy_and_prove: failed` 的线写成了「winding down」。 + **它的编排还活着,而框架可能重试一个失败的任务**——那会在一个有活跃外来租户的 + 节点上,无人看管地起一次新的 bring-up,带着 `--timeout 21600` 和没有天花板。 + *今天第二次:一个被判定「已完成」的进程仍有行动能力。第一次是那个在容器被停 + 一分钟后重建了一条臂的 agent。* +- **一个错的发车变量,在 `-noval` 下活过了整整一个下午,而打开一个 validator + 五秒就把它照了出来。**(m1,2026-09-05,今天关于「关掉验证的绿买到了什么」 + 最具体的一条,而且比抽象版本强,因为那个错值活过了一次所有人都在引用的运行。) + ``` + # check_trace_coverage + note: 2 rank(s), 419218 GPU kernel events + PROBLEM: expected 4 rank(s), the manifest lists 2 + ``` + `RUN-PLAN` 的变量表本来就写着:m2 被回放时 `expect_ranks` 是 **2**(语料是 + 09-02 的 TP-2 采集)。m1 从更早的血统里继承了 `4`。 + **要害不是这个错误,是它藏在哪里:同一个 `expect_ranks=4` 在那次 78 分钟的 + `-noval` 基线里同样是错的,而没有任何东西说出来——因为没有 validator 在看。** + > **打开一个 validator,让一个错了几小时的发车变量第一次可见。 + > 这就是「关掉验证的绿」买不到的东西的实例。** + *附带:`check_trace_coverage` 因此升级了——它在一条真实链上拒绝过一次, + 点名了字段、给出了数字。而且它写报告,所以定位花了五秒,不是十五分钟。* +- **一个字段的职责就是说「这东西来自别处」——它是最不该被洗成占位符的那个。** + (m4,2026-09-05,回答我那个契约问题时给的,而且理由不是可移植性,是证据性。) + 我的 `kernel_optimization.json` 里嵌着 `work_root: /mnt/…/e2e_flow_088a`, + `redact` 因此拒绝。看起来该「把路径抹掉」。 + **而今天最贵的几次调查,恰恰是缺这种信号:** 一份在 093 上流通的产物里写着 + `e2e_flow_088a`,**正是那种需要一次 `store/task` 才能定案的出处信号。** + > **抹掉它就是销毁证据,而不是提高可移植性。** +- **`redact` 的拒绝按文件后缀划界,而它自己的理由按内容性质划界。**(同上。) + `redact.py:56` `REFUSE_SUFFIXES = {".py",".sh",".json",".jsonl"}`, + 而 docstring 说的是*「一个带着某台主机目录结构的脚本,在下一台机器上跑不起来」*, + 并且**有意放过散文**(*「散文里写 `/v1/models` 不会烙进任何主机的目录结构」*)。 + **`premise.*_environment` 在散文那一侧——没有任何东西通过它定位任何东西, + 记录自己的注释就说消费者该读握手。是 `.json` 这个后缀把它归进了可执行那一桶。** + *这就是 T75 那条更正的逐字重演:按种类约束,不要按文件名。* +- **「再加一个前缀」是跑步机,而每一圈的代价是一次真实部署之后的 `exit 1`。**(同上。) + `redact` 的前缀表由**本次运行**的环境装配(`packup.py:515-527`), + 而嵌进去的记录带的是**另一次运行**的根。**从活环境装配的前缀表,永远盖不住外来的根。** + **代码库自己早就知道这件事——`MOCK_ROOT` 存在的理由,正是「一份被回放的 kit + 记着封存它那次运行的根」。** 那是一个已知的外来根;逐字继承产生的是无界集合。 +- **监控器的三种失效,代价从小到大:漏报 < 每轮都喊 < 中途静默死掉。** + (m2 给了前两个,m4 补了第三个,2026-09-05。) + m2:*「一个每个周期都在喊的探针会被无视,而那和『它是错的』代价相同。」* + m4:**「一个中途静默死掉的探针比这两者都贵——运行还在继续,而没有人知道 + 它已经不在看了。」** 那正是 m4 今天下午那四分钟的空档。 +- **不要直接执行共享仓库里的脚本当长驻进程——bash 是惰性读取的。** + (m4,2026-09-05,而且他们据此没有执行我的指示,是对的。) + 改一个**正在运行**的 bash 脚本会移动它尚未读到的偏移量。 + `assets/lib/idle_watch.sh` 今天已经被改过两次——**任何从那个路径直接起的长驻 + 进程,离「中途开始执行垃圾」只差一次别人的编辑,而现象是「探针莫名其妙崩了」。** + **解法不是回到各自复制,而是「带出处的快照」:** + ``` + # SNAPSHOT of .../assets/lib/idle_watch.sh + # taken 2026-09-05T17:43:49Z at HEAD=b8cacab, sha256[0:16]=7d349bb191bae81e + ``` + **它不重开「三份副本」那个问题,因为副本点名了自己的来源、commit 和摘要—— + 陈旧变成可检测的(`sha256sum` 一比就知道),而不是隐形的。** + *时间戳是同一条命令里 `date -u` 读出来的,不是手写的。* +- **一个每个周期都在喊的探针会被无视,而那和「它是错的」代价相同。** + (m2,2026-09-05,把自己 v2 的首版当成回归而不是小毛病。) + 他们修上面那条盲区时,首版用**精确匹配**任务——而 phase 行经常点名一个 + 非叶子,真正干活的是它的子节点,**于是健康的运行每轮都被判 BLIND**。 + 改成沿 `parent` 链上溯,并在发布前用四个用例测过(含这次回归)。 + > **一个探针的假阳性率,和它的假阴性率一样是它的正确性的一部分。** +- **一个在「观察对象不存在」时**换一个对象继续汇报**的仪器,比一个沉默的仪器更坏。** + (m4,2026-09-05,在自己那条死掉的运行上发现,而且推翻了自己写的 header。) + 停滞探针取 run 树里**最新的那个 `.jsonl`**。而卡住的那个阶段 + `run_profiling_mode_off` 是 `${m2_agent:-runner}`——**程序体,没有 transcript。** + 于是它拿到了 m1 的: + ``` + IDLE-AGENT SUSPECT: transcript 845s stale while + 'm1_deploy: output_validating -> succeeded' + ``` + **每个字都是真的,而结论不可用**:它点名了一个**已经成功**的阶段, + 而真正卡住的那个阶段仪器根本看不见。 + **m4 在装它之前就在 header 里写了「对程序体它会永远打 no transcript yet」—— + 实际更坏:在混合图里它不会沉默,它会去报另一个阶段。** + > **沉默是可检测的;换个对象继续自信地汇报不是。** + **读法必须改成「树里最新的 transcript 陈旧了」,而不是「这个阶段的 agent 空转」—— + 两者恰好在「当前阶段是程序体」时分叉。** + *附一条正面的:m4 用 `cmp` 对 `HEAD:` 逐字节核了重新生成的文件, + **而不是 grep 一个关键词——「grep 在旧副本上一样会通过」。*** +- **`bash -n` 只验语法,一条注释放进反斜杠续行块里会吞掉后面的命令,而它照样通过。** + (m2,2026-09-05,自己在发出前读打印出来的块才抓到。) + 续行块里的注释会接到上一行上,**把这条命令的其余部分一起吃掉**—— + **`bash -n` 接受它。** 和 `ast.parse` 同族:**仪器无法以你需要的方式失败。** + 他们的做法值得抄:**注释挪到块外,并在原地写明这个陷阱,然后用行为测试代替 + 语法检查**(回环握手 → 回环;节点 IP 握手 → 节点 IP;无握手 → 组合出的回退)。 + + **同族第三个,而它给出了「什么时候解析救不了你」的判据**(m1,2026-09-05 22:2x, + 修我撞到的那个 `sh` / `bash` 头部错误时)。两个脚本同一个缺陷:头部写 `sh`、 + shebang 写 `bash`、body 用 `set -o pipefail`。 + ``` + dash -n stop_line.sh -> 沉默。通过。 + sh assets/lib/stop_line.sh -> line 56: set: Illegal option -o pipefail + ``` + > **`dash -n` 不可能在一个非法选项上失败,因为它是语法检查,而 `-o pipefail` + > 语法完全合法。** + 而 `refusal_saw_something.sh` **确实**没通过 `dash -n`——它卡在进程替换上。 + **同一类缺陷,一个被解析抓住、另一个只能被执行抓住,这就是「跑一遍」的全部理由。** + *附:m1 是查了整类而不是我撞到的那一个,于是找到第二个从没有人用 `sh` 跑过的脚本; + 两处现在都写明 `bash` 并写明**为什么**,免得被人「简化」回去。* +- **一个只守住复合值里某一个字段的修复,会把它的兄弟字段留在原地——而那个修复本身是对的。** + (2026-09-05 已有三个实例,今天最会重复的一类。) + 1. **`HS_ENDPOINT`**(m4 发现):早先一次 abort 是端口算错,修法是 + **不再计算端口、改为读握手**——**这个修法对,现在也仍然对**。 + 但 `HS_PORT` 从 `HS_ENDPOINT` 里解析出来并被校验,**同一个字段里的 host + 被逐字使用**。于是 kit 记下 `http://:`,而路由器绑的是 + `127.0.0.1`:一次成功的 TP4 起机(227 秒、`verify: PASS`)之后, + 负载步骤拨了一个没人监听的主机。 + 2. **bug 30**(也是 m4):回退把 `work_root` 搬走了,**没搬 `scratch_root`**—— + 两者同前缀、同一条发车行,却不共回退。 + 3. **`_agree_or_die`**:28 个环境字段里守 3 个。 + > **修一个复合值里的一个字段时,把同一个值里其余字段点名一遍, + > 说清哪些被守住了、哪些没有。「这一处修对了」不蕴含「这一类修完了」。** + *附带一条正面的:`line.sh:318` 上方的注释保留了上一次 abort 的运行号和症状, + **m4 因此几分钟就定位到,而不是几小时。修 bug 时最想删掉的就是那个 bug 的故事。*** + + **同一晚上两次,而它比「兄弟字段」那版更阴:一对变量里在场的那一个, + 让缺席的那一个看起来已经覆盖了。**(m2,2026-09-06。) + + | 需要两个 | 我的发车行有 | 缺的 | 后果 | + |---|---|---|---| + | `remote_home`(四个 task 的 env)+ `transport_env`(三个 validator 的封闭环境) | 前者(而且 agent 还自己 export 了一遍) | 后者 | `check_workset_runs` 拒绝一份**完好**的产物 | + | `measure_gpu`(workset 测量)+ `gpu`(m4 的 `HIP_VISIBLE_DEVICES`) | 前者 | 后者 | m4 一个 shape 都没测,两个 validator 拒绝一份**从未被生产出来**的产物 | + + > **两个名字相近、消费者不同的变量,清点时会被当成一个。** + > **而缺席的那一半的失败,长得像产物的缺陷,不像发车行的缺陷。** + + **修法不是「再读一遍变量表」——我那晚读了两遍,表本身是错的。** + 是把「包里所有空默认变量」减去「发车行上的变量」,两条命令,**在发车之前**: + ```sh + grep -rhoE '\$\{([a-z_0-9]+):-\}' steps/*.yaml shared.yaml | sed 's/[${}]//g; s/:-//' | sort -u > /tmp/empty.txt + <发车行里的 --var 名字> | sort -u > /tmp/have.txt + comm -23 /tmp/empty.txt /tmp/have.txt + ``` + **而这道守卫只对「已经实测过会 abort 的那个」中止,其余只打印。** + 理由见「加一道守卫之前先读这一条」:把分类不了的一律拒掉,就是把一个未知 + 变成一次死亡。 +- **mock 满足了一个真实路径根本无法满足的检查——于是那个 body 的整个生命期都是隐形的。** + (m5,2026-09-05,今天最尖的一条,而且方向和我们担心的相反。) + `compare.py` **从来没有写过 environment 记录,一次都没有**,而 + `check_environment` 是 `strong`、覆盖全部十五个 kind——**所以 + `integration_report` 这个 kind 从来不可能通过它。** + 为什么没人发现:**此前每一级 rung 的阶段 5 都是 mock 的,而 `mock_m5.sh` + 自己渲染那份记录。** + > **我们一整天担心的是「mock 造出生产者从不写的东西,于是 validator 在虚构上通过」。 + > 而后果比假通过更重:它藏起了一个根本无法满足该检查的 body。** + **只有一份真实的 `integration_report` 才能暴露它,而今天才产出第一份。** + *这正是 CONTRACT §5.3 存在的理由。修完之后他们做了「弄坏 → 放回 → 通过」的控制: + 同一份产物上 `false → true`,而且是跑出来的不是读出来的。* +- **`v0` 是空的不稀奇——凡是重试过的东西,`v0` 空是常态。** + (m5,同一小时内两次栽在这上面。)他们两次从一个空目录推出「缺失」, + 两次内容都在更后面的版本里。**修法是 tier 1:先枚举版本、取有内容的那个,再开口。** +- **一份 `--inherit` 来的记录,证明「调用发生过」,但它不是第二个来源。** + (m4,2026-09-05,主动标出来的。)`optimize_kernel` 的活路径确实写了 + `items/codes/environment.yaml`,而且**有守卫**(输入缺失就 die)—— + 但那是 `env_render --inherit`,**内容是 m1 记录的转发,不是独立测量。** + **正是「一个来源被拆成两份」那种情形**,任何拿下游记录当佐证的方案都要先过这一关。 +- **「没有解释」和「解释算出来了、然后被扔掉」是两个缺陷,修法完全不同。** + (readme-cn,2026-09-05,而且他们去读了代码路径才敢这么写。) + `check_environment` 拒绝三个 kind,报告里**没有任何 `PROBLEM` 行**, + 看起来是「这个 validator 从不解释自己」。**实际上:** + ``` + find_record() 第二个返回值就是 "no environment.yaml at any of [...]" + main() 把它 append 进 findings,然后 print(..., file=sys.stderr) + stderr 被丢弃 + ``` + **句子是算出来的,而且写出来了——写去了一个没人保留的地方。** + > **在断言「没有解释可用」之前,先查一遍解释是不是被算出来又扔了。 + > 前者要有人去写,后者只要一根管子。** + 他们没有把它当新发现立,而是认出它是 `bug.record` 里那条 + **「validator 的 stdout 无处留存」今天第二次计费**。 + *而这也把成本挪了位置:上一次的代价是 m1 花十五分钟把那句话重新推导出来; + 这一次那句话存在——只存在于一份诊断和 T76 里。从 `verdict.json` 出发的人, + 拿到的仍然是三个 `false` 和同一条泛泛的升级信息。* +- **一个拿「本次运行没有使用的值」去评分的工具,会把差异报成产物的缺陷。** + (m5,2026-09-05,自查探针时发现,而且这是今天第三个同族。) + 他们的 `probe_validators.py` 把 `MOCK_VARS` 硬编码成 `expect_ranks=2, + adhoc_cases=0`——对 09-02 语料是对的。**拿去评一条跑了 `expect_ranks=4`、 + `bench_rounds=3` 的链,凭空造出三个拒绝:** + ``` + check_trace_coverage "expected 2 rank(s), the manifest lists 4" + check_bench_report "3 replay round(s) present, 1 expected" ×2 + ``` + 加上 `--var K=V` 之后,**同样的产物、同样的探针:29/13 → 32/10。** + **同族:`mock_stages` 什么都没 mock、`--var gpu_devices` 是失效的—— + 发车行和检查器静默地不一致,而挨骂的是产物。** +- **同一个探针的第二个缺陷:理由全在磁盘上,一条都没打印出来。** + 它取第一条匹配 `PROBLEM|REFUSED` 的行,**而 validator 的报告以 + `## probe-: REFUSED` 开头——所以永远先匹配到标题。 + 十三个拒绝把自己的标题回报了回来。** + **一个「摘要工具」可以在完全不出错的情况下,把它要摘的东西全部丢掉。** +- **一个 agent 写了 4.4 KB 说明自己为什么拒绝,而我们花一小时读进程表。** + (m5,2026-09-05,`integrate_and_verify` 在 15:07 中止,`ABORT.md` 在它的 zone 里。) + **它的判断比当天任何一个人都好:** + - 它拒绝,是因为端口 5557/8801 被**同一个包的另一次运行**占着—— + **正是 m5 自己那条孤儿运行的臂,由孤儿 agent 在 15:00:09 重建, + 距离容器被停只过了一分钟**(「agent 活过编排并重建容器」那条的又一实例); + - **归属按 label 判,不按名字**:`infera_e2e_run=yihou_m5_237c_stock` ≠ 自己的 + `$E2E_CONTAINER`,原话*「不是这个任务创建的名字,就不是这个任务可以删的名字」*; + - **它拒绝执行 `reset_gpus.sh`**(`mix_up.sh:81`)——那是节点级 `kill -9`, + 会毁掉另一次运行的引擎。**同一个隐患,一整个下午耗掉了我们两条线和一次误杀。** + - 它还记下了自己不得不自行供给的两个值,包括 `E2E_GPU_DEVICES=0,1,2,3`, + 并注明**那不是选择**。 + **产物在磁盘上躺了一小时,而我们在读进程表。** 今天第五次同形。 +- **比较节点状态时,引用容器的**启动时刻**,不要引用 `Up N minutes`。** + (m3,2026-09-05,一次「看起来是矛盾」的事后。) + 他们报 `serves-dcd949cd` **Up 6 minutes**,我报它已经没了。**两个都对,相差约 + 90 秒。** 但两个读数**单位不同**:一个是相对时长,一个是绝对观察, + **而相对时长会静默地重新锚定到「你读它的那一刻」。** + > **`Up 6 minutes` 不和任何东西可比,除非你同时知道它是几点打印的。** + 修法是一条命令:`docker inspect -f '{{.State.StartedAt}}'`, + 报 `started 16:48:05` 而不是 `Up 6 minutes`。 + **和 `ls --time-style=+'%F %T'` 同族**——那次是丢了日期,这次是丢了锚点。 +- **审计「某文档有没有写明要求 R」时,grep 的命中不是证据,不命中才是。** + (2026-09-05 一小时内三个实例,两个人。)要求本身的字眼会因为**无关的原因** + 出现在文档里: + - readme-cn 搜 brief 里的 `## Purpose` —— 命中的是 **brief 自己的结构**, + 不是它叫 agent 写什么; + - m2 的三个 brief 里有两个**自带 `## Watch out` 标题**,agent 读了可能 + 以为要求已满足; + - m5 的 brief `:140` 有 `*Watch out:*` —— 一句讲 bring-up 日志的散文。 + **三次都是「像模像样的命中」。** 结构上不对称: + > **不命中能证明「没写」;命中什么也证明不了,必须去读那一处在说什么。** + 推论(readme-cn 做对的):**把这个陷阱写进审计结论里**—— + 「下一个来 grep 的人会撞上 `:140`,那不是它」——否则下一个人要重新推导一遍, + 而且可能推反。 +- **撤回一个推断时,不要把它挂靠的那份一手证据一起撤掉。** + (m2 收尾时做的调和,2026-09-05。) + m4 从「`exit_status: finished` + 死掉的 transcript」推出「agent 已退出」, + 错了,于是连带撤回了「推送没有接收方」并说「我不再猜」。 + **但那句话不是猜的——它是框架自己的异常文本,一手躺在 `store/event` 里:** + *「instruct(...) 没有 loop 可投递。agent 已完成,`mainloop` 已返回。」* + **调和:`mainloop` 返回不等于进程退出。异常描述的是 loop,`ps` 描述的是进程。 + 两个人都对,而且说的是两个不同的对象。** + → **一次过度的撤回,会把当天最好的发现和那个错误的推断一起扔掉。** + *「撤回也可能像主张一样错」已有;这一条是它的具体形态:撤回的边界要和推断 + 对齐,不能和情绪对齐。* +- **`pkill -f <你自己脚本的名字>` 永远自匹配,`[p]attern` 那个技巧也救不了。** + (m2 与 m4,2026-09-05 同一小时各中一次,而 `lines.sh` 的那条教训已经读过八小时。) + m4 的 `pkill -f p5_idle_watch.sh` 杀掉了自己的包装 shell,**连同还没执行到的 + 重启那一行**;m2 同形。**而 `[p]8_idle_watch` 挡不住**——那个字面串在同一条 + 命令行的**别处**还会出现。 + > **tier 1 的形状:先用一个**不同的方法**解析出 pid,再按 pid 杀。 + > 永远不要用一个出现在你自己调用里的模式去 `pkill` —— **`pgrep` 同样**。** + *m2 今天第三次栽在这上面(`pgrep -f "p9_idle_watch"` 杀掉了自己的 shell), + 他们自己的结论:「它现在给我造成的代价,已经超过我用它找到的 bug。」 + 三次都只是因为真正的目标先被杀掉了,活才落了地。* + **第四次不是我的 shell,是别人的活。**(m2,2026-09-05 19:31。) + ``` + R=$(ls -dt .../runs/20260905T193*/ | head -1) # 运行目录是 1929,不匹配 -> R 为空 + case "$c" in *"$(basename ${R%/})"*) # 模式退化成 * * -> 匹配一切 + kill -TERM 292129 553438 615282 651741 # 四个 agent,只有一个是我的 + ``` + 杀掉了 m1 在 217 上跑了 22 分钟的 `deploy_and_prove`,以及 m5 在 237 上 + `integrate_and_verify` —— **那条链当时最深的一点,也是唯一会回答 + `check_measurement_order` 的那次运行。** + > **真正的缺陷不是 glob,是「一个算出来的值,没有校验非空就进了一条破坏性命令」。** + > `[ -n "$R" ] || exit 1` 就是全部修法,**而它属于每一个流向破坏性命令的派生值, + > 不只是 `$R`。** + *两条附带的,都值钱:* + - *前三次只赔了我自己的 shell,**所以这个模式被我定价成"烦人"而不是"危险"。 + 廉价的实例会教出关于昂贵实例的错误结论。*** + - *`143` 以「不明崩溃」的形式出现在我自己的运行日志里——那是我自己的 SIGTERM; + 而**同一个分钟边界**(19:29 vs 19:30)既造出了那个空变量,又让我以为进程表里 + 多了一条新线。* +- **一条走进死胡同的链,默认会留下停驻的 agent —— 二比二。**(m2,两次都实测。) + 217 和 093 的 `p8` 都是:三个 `claude/versions` 停在 `ep_poll`,加上编排进程。 + **所以每一条死掉的链都需要显式拆除,而不是弃置。** + *而 `ep_poll` 停驻本身不是死因特征(m4):成功阶段的 agent 也长这样, + 其中一个已经停了 1 小时 51 分。* +- **「未回答」和「从这份数据里根本够不着」是两回事。**(m3 更正 m4,2026-09-05。) + m4 一直把「它在真实运行里会不会驳回」当成「知道了发车值就能回答」的问题。 + **不能:两条运行都是 `-noval`,运行中根本没有过 `check_trace_coverage` 的裁决, + 只有 `check_nothing`。没有可比的对照,所以那个反事实在两个方向上都够不着。** + > **离线评分确立的是「这个 validator 现在说什么」,而不是「它当时会说什么」。** + *我们今天一直在说「未知,不是失败」——这是更尖的一版:不是还没答,是从这份数据 + 里问不出来。而 m4 把被推翻的那个读法**标出来而不是删掉**, + 让下一个读者看见它当初错在哪一侧。* +- **今天最强的一份证据,是两条真正不同的路径落在同一个数上。** + `items/env/trace_manifest.json` 枚举了 rank 0–3,四条各带自己的字节数、sha256、 + 事件数、时间跨度——**四条的 `gpu_kernels` 加起来正好 823736**; + 而 `check_trace_coverage` 是**从头重新解析 trace** 得到同一个数。 + **一个是清点,一个是重算,两条都不是拷贝。** + *对照前面那条:m2 与 m4 各自重解同一个 trace,那是一个方法执行两次; + 这一次才是两个方法。**判据不是「几个人得到了同一个数」,是「他们用的是不是同一条路」。*** +- **一份冻结记录的可信度,来自「改过什么是可查的」,而不是来自「从没被碰过」。** + (readme-cn,2026-09-05,他们据此才肯动那份预先登记记录。) + 修法把限定词搬进了加粗句内部,并且**在原地记了一条变更日志**:什么动了 + (可引用单位的边界)、什么没动(零个数字、零个等级归档)、以及它和今早被 + 拒绝的「回填」有何不同。 + > **「这份基线从没被编辑过」是一个在它不再为真的那一刻就失效的主张; + > 「这一次编辑做了什么」则一直可查。** + *推论:冻结不是不许改,是不许悄悄改。* +- **先问这个缺陷在源头还是在传输途中——修法完全不同。** + (readme-cn,同一条提交里最锋利的一句。) + > **今天关于这个数字的错误,全部发生在引用它的路上,没有一个发生在产生它的地方。** + 那把梯子产出了一个正确的 0 → 1;**我两次对用户说错、他们把自己的快照读成当前值、 + 四个人接力搬错——全部在传输里。** + **一个源头可靠、传输有损的机制,要的是排版修复,不是机制修改。** + *他们对自己那次的诊断同形:「我查的是限定词在不在,然后就停了—— + 仪器回答的是『有没有写』,而问题是『它会不会跟着走』。」 + 和 grep 一份 brief 自己的标题是同一个错。* +- **加粗的那一行才是会被搬走的单位——限定词写在它上面一行,等于没写。** + (m3,2026-09-05,更正了我对同一件事的诊断。) + 我把它记成「一个读起来像活数字的冻结基线」。**不对:那个基线是标注过的。** + ``` + **目前的分布(2026-09-05T13:xx,结果到来前):** + + - **第 4 级:0 个。** 本包至今没有任何一个 validator 达到过。 + ``` + **「结果到来前」带着时间戳,就在上面一行。** 而限定词在普通正文里, + 主张在加粗的项目符号里——**加粗、带数字、自成一句,那才是可引用单位。 + 它经过了四个人,而上面那一行一步都没跟着走。** + > **这是散文里的 `tail -1`。** 和那四条「死因不明」的运行同形—— + > 死因就写在最后一行的上面一行,而我的 grep 把它扔了。 + **所以这不是文档缺口,是排版缺口:什么被加粗,什么就被搬走。** + → **修法不是改那个数,是把限定词搬进加粗的那一句里。** + (2026-09-05,今天三个人先后把 §2.8 的「第 4 级:0 个」读成当前状态,包括我, + 我还对用户说了两次。) + 那个零是**预先登记的快照,刻意不回填**——而 §2.9 早已归档了一个第 4 级、一个第 3 级。 + **不能为了「保持最新」去改基线,那会毁掉它存在的理由。** + > **所以修法是:在每一个会被当成计数来读的地方,写明「当前值读 §2.9,不要读这里」。** + **而 m4 指出了这句假话真正的落点:他们把它写进了 `grade_offline.py` 的 docstring + ——已提交、在仓库里。** 他们的话: + > **消息是可以撤回的;那一行不会,而下一个人会把它读成当前状态——你就是这么拿到它的。** + → **一句错话在消息里,代价是一次对话;在被提交的文件里,代价是此后每一个读者。** + *他们改之前自己去核了 §2.9,理由是「撤回可能和被撤回的主张一样错」—— + 那条是我今早自己撤回之后写下的。* +- **两条路都终止在同一个字段,就不是两个来源。**(m3,2026-09-05,而且它切的是 + 所有人都想要的那个答案。) + m4 从 `tp_size: 4` 推出 `expect_ranks=4`;m3 的 4 来自发车块的规则, + 而那条规则字面就是 `--var expect_ranks=`。 + **所以这次确认成立的是「那次运行用的是 4」——正是 m4 需要的,因为它意味着 + 他不是在给自己编的参数打分;但它不确认「4 是对的」有两条独立路径。** + **真正独立的那一条被他们点了出来:`items/env/trace_manifest.json` 里 + `ranks: 4`,是部署自己数出来的。** + *今天第三次用上「来源独立不等于方法独立」,而这一次是用在一个便利的确认上。* +- **拿到了正确的答案,却错过了那个一般事实。**(m2,2026-09-05,自己举证。) + 他们建验证 zone 时也撞上了「内容在 `v1/` 而 store 写 `version: 0`」, + **写下了 `.../v1/content`,因为文件就在那儿——却从没问过 store 为什么说 `0`。** + 于是他们的评分是对的,**而那个会让 m4 少掉四个假驳回的一般事实没有被提取出来。** + > **「路径对了」和「知道为什么路径是这个」是两件事,而只有后者会传给下一个人。** + *m4 把它变成了 `grade_offline.py` 里的一个守卫;m2 拿到了对的路径、漏掉了那个事实。* +- **同一个成因的多个实例,会因为「被当成事件来数」而彼此隔离。** + (m4,2026-09-05,给 T79 写的那句。) + > **第五个是在评分时发现的,第四个是撞车撞出来的,第一个是审计出来的—— + > 一个成因,三种不同的意外。把它们当成事件来数,正是它们一直分开的原因。** + **推论:把它们并成一条的动作本身就是发现,而不是整理。** +- **一条没有重开条件的押后,两天后和「遗漏」长得一模一样。** + (readme-cn,2026-09-05,给 §2.12 补的。) + 他们给「§2.8 措辞该不该改」的押后加了触发条件——**「当这个问题不再挂着一个 + 待定的计数时」**——而不是一个日期。**和这份文件拒绝无限定的「永远」是同一条理由。** +- **一次 PASS 确立的是「validator 被调用了」,不是「它看到了什么」。** + (m1,2026-09-05,今天最该往前排的一句,而且它反了我下的筛查方向。) + 同一个「零文件 materials」的故障**也会产生通过**,而且是在他们当作对照的那次运行里: + ``` + w17m1e zone …773bf177 materials 0 个文件 args [] 无报告 verdict: true + p6m1 签名完全相同 materials 33 个文件 args [] 无报告 verdict: true + w17m1g check_profiling_evidence 0 个文件 → 拒绝 + ``` + **故障是非确定性的、与 validator 无关的;它可见与否只取决于落在谁身上:** + 落在严格的上 → 一次读起来像生产者缺陷的**误拒**; + 落在宽松的上 → **在空目录上通过,彻底不可见。** + > **我的判据「引用了文件内部数字的拒绝证明它读过文件」只扫得了拒绝。 + > 一次通过不附带任何理由,所以那个判据对它根本无法施加。** + **唯一的事后判别是 zone 的 `materials/` 文件数**——工具: + `bash assets/lib/refusal_saw_something.sh `(头部写的是 `sh`, + 但第 39 行用了 `set -o pipefail`,dash 跑不了)。 +- **撤回不是安全动作,它是另一种出错方式。**(m1,2026-09-05,自己举证,而我也上当了。) + 他们先把 apply 那件事说强了,再撤回说弱了,**而第二版是错的**。 + 他们的自诊:*「我是在一个**呈现信号**上撤回的——那行有 `NOTE` 前缀、还写明了自己的 + 后果,于是我把标签当成了关于严重性的证据。我读的是输出怎么被标注的,不是那个函数。」* + **和「读 phase 日志而不是 transcript」同类:更便宜的产物在手边、说得通、于是停下。** + > **今天我一直把「往回收」当成安全的一步——它不安全,它只是换一个方向错。** + *而我采纳了那次撤回并把它归档,正因为撤回看起来安全。 + 「检查主张,不论它指向哪个方向」这条,我们两个都没有用在自己的更正上。* +- **第三种后果,而它是最坏的:同一个故障会杀掉一次正在正确进行的运行。** + (2026-09-05 23:16:58,`r5m1b`,我发现、m1 独立复核。**这条要排在下面那条前面, + 因为前两种败坏我们「相信什么」,这一种毁掉正在正确进行的工作。**) + + | 它落在哪 | 结果 | 可见吗 | + |---|---|---| + | 严格的 validator | 读起来像生产者缺陷的误拒 | 可见,而且误导 | + | 宽松的 | 在空目录上通过 | **不可见** | + | **严格的,而且在活跃工作的上游** | **杀掉一次健康的运行** | 只在那一行 `done` 里 | + + ``` + verdict check_profiling_evidence: FAIL ← zone 被递了 0 个文件 + handoff integration_report slot v0: generating ← 阶段 5 正在写报告 + done main 在等一个没有人会做的决定 …… 900 秒没有变化 + ``` + **因果链四步,没有一步提到那个其实完好的产物**:空目录 → validator 无物可读 → + escalation 没有接收方 → 900 秒计时器。**而失败的那个阶段是 mock 的,根本不在被测之列。** + **`integration_report slot v0: generating` 是定案的那一行**(m1 找出来的): + 阶段 5 不只是在跑,**它正在写报告**。 + > **发生率约「11–13 个 zone 里 1 个」——高到「任何一次运行的绿都该查而不是假定」, + > 又低到「重跑不构成诊断」。** + **解释它的那一行不是最后一行**:底部 `run complete; the run did NOT finish` 读起来 + 像普通收尾,原因在它上面 108 行。**今天第二次「最后一行是错的那一行」。** +- **框架会把一个**零文件**的目录送进验证区,而那份拒绝报告读起来完全像一份对产物的判决。** + **(标题原为「一个空的 `v0`」——错的,见下方更正。)** + (m1,2026-09-05,用受控对照证实,今天最坏的一次仪器失效。) + ``` + w17m1e PASS materials/dc334dbd…/v1 45 files + w17m1g REFUSE materials/0704b267…/v0 0 files + handoff 0704b267: v0 = 0 个文件,v1 = 47 个 + ``` + **同一个包、同一份语料、同一个 validator、同一个 kind**,只有两个 m2 的 merge + 不读的变量不同。`check_profiling_evidence` 给出八条 `PROBLEM:`、点名八个缺失文件—— + **而那八个文件在拒绝发生的那一刻全都在封存内容里。** + > **报告格式良好,每一句都是它被指向的那个目录的真话。verdict、报告、升级信息里 + > 没有任何一处提到 `v0`。** + **今天第三个 `v0` 事件,而这一个是框架的**(前两个是 m5 的推断和 m4 的工具, + 都能在自己代码里修)。**body 无法防御「被递了错的目录」。** + **更正(m1,2026-09-05 20:3x,推翻的是我这条记录的标题和我发给所有人的那条命令): + `v0` 不是缺陷。** 在同一次通过的运行里,`check_acceptance` 是从 **`v0` 且有 97 个文件** + 被 stage 的,而 `profiling_evidence` 是从 `v1`。**`v0` 是一个正常的版本,可以是满的。** + > **缺陷是「被递了一个零文件的目录,而同一个 handoff 的另一个版本有 47 个」—— + > 和版本号无关。判据是文件数,版本号是噪音。** + *照我原话去查的人,会去找「materials 里有 v0」,到处都能找到,然后什么都得不出。* + **而推翻它的证据二十分钟前就在 readme-cn 的表里**——`integration_report v0=6(唯一目录) + → 指向 v0,6 个文件,有内容`——**我读过那张表,没有把那一行和我的标题连起来。** + 又一次「答案已经被取回来了,只是没有被读」。 + **判据(可查,而且能反向筛今天所有的拒绝):** + > **一条引用了文件内部数值的拒绝,证明它读过那个文件;一条只说「X 缺失」的,不能。** + 安全的例子:`419218 对 826040`、`expected 4, the manifest lists 2`——都读了内容。 + **而 `check_optimization_shape` 那条给出了可推广的设计要求(m1): + 它报的是 `found: ['...packup_noop_20260905']`——枚举了自己实际看到的东西。** + > **一条枚举实际内容的拒绝,不可能是在看一个空目录,因此对这个故障自免疫。** + **这是对每一个 validator 都便宜的要求,而它会让今天这整场回溯筛查变得不必要。** + **需要复查的:`check_environment` 那三条「no environment.yaml at any of [...]」——纯缺席。** + **识别命令(一条,该贴在每次运行旁边而不是记在谁脑子里):** + ```sh + z=$(find -type d -name 'validation.*output_validation*' | head -1) + find "$z/materials" -maxdepth 2 ; find "$z/materials" -type f | wc -l + ``` + **限定(readme-cn 复查后,而且是对我转述口径的收窄):这对对照证明框架 + 「能」stage 一个空的 `v0`,不证明它在别处也这么做过。** + 他们那次运行里 `deploy_kit`、`operator_workset`、`profiling_evidence` + **都是「`v0` 空、`v1` 有内容」的形状,而且全部通过**—— + **m1 找到的那个形状在这个包里很常见,在这里是良性的。** + 而最强的一个数据点是 `e2e_packup`:**它有两个空的版本目录(`v0` 和 `v1`), + 验证区两个都跳过了,直接指向 `v2`——那不是「`v0` 碰巧有内容」, + 那是版本解析在跨越一个两级的空洞。** + *他们还多走了一步:光看版本只能说明「这次拒绝不是被空目录解释的」, + 不能重新确认那个正面主张。所以他们直接列了**被 stage 的**那棵树, + 三个 kind 都确实没有 `.yaml`——**即便拿到正确的版本,`check_environment` + 照样会拒绝,因为生产者没写。** T76 因此不是「未被推翻」,是「被检验后仍然成立」。* + *m1 还指出它击败了显而易见的下一步:他们第一个假设是 `bench_rounds=1` 改变了 + m2 的产出——合理、可测、而且完全错。**重跑不会推翻它,因为这个故障不由发车行决定。*** +- **一条教训不是因为「没被读」而失效,是因为「你写下它所管辖的那一行时它不在场」。** + (m4,2026-09-05,今天关于「为什么写下来不够」最锋利的一句。) + m5 那条「`v0` 可能是空的」今早就在这个文件里,**而 m4 今天读过这个文件**, + 仍然写下了 `f"v{v['version']}"`——**因为那一刻他脑子里是 validator 的参数。** + > **「读得更仔细」在这里不是一个可用的答案,因为他已经读过了。** + **而他推出的结论比观察更强:** + > **值得带走的版本不是「记住 `v0` 可能是空的」,是 + > 「在信任一件新仪器之前,先跑一个已知答案的案例」。** + **一个机制抓住了他两个缺陷;两条记忆一个也抓不住。** + → **这是「只有第三类会衰减」的最强论据,也是 m2「存放位置」那一轴的延伸: + 不只是教训存在哪里,而是它在它所管辖的那一行是否在场。** +- **一次运行不记录它是用什么 `--var` 发的车——发车行从产物里恢复不出来。** + (m4,2026-09-05,拿 RUN-PLAN 的配方对自己已完成的工作时发现的。) + `p9` 的 `zones/…/package/steps/m2_profiling.yaml` 至今写着 + `expect_ranks: '${expect_ranks:-8}'`——**被 stage 的那份副本没有被渲染。** + 于是「这次运行实际传了 4 还是用了默认 8」无处可查, + 而 `check_trace_coverage` 的裁决恰好是这个值的函数。 + > **一个取决于「运行本身不记录的参数」的判决,是一个薄的判决。** + **今天至少四件事是同一个缺陷:** + - `produced_by.commit = 'unknown'` —— 值是可知的、没人供给,而且没有东西记下「没人供给」; + - `expect_ranks=4` 在那次 78 分钟的 `-noval` 基线里错了一下午,靠打开一个 validator 才现形; + - 2c 那一整类 —— mock 值和真实值,带错方向就产生「像生产者缺陷」的驳回; + - `--var gpu_devices` 连续五次失效,而默认值恰好相同所以查不出来。 + **四件都是「发车行从产物里恢复不出来」。** +- **一个防「未渲染的 `${...}`」的守卫,抓不住「渲染成了错误默认值」。** + (m4,2026-09-05,而且它和今天下午那条是同一机制的两个症状。) + ``` + --var gpu_devices 默认恰好等于所有人都要的 0,1,2,3 → 藏起一个失效的 flag(安静) + expect_ranks 默认 8,而三条运行都是 TP4 → 制造四次驳回(吵闹) + ``` + **同一个机制,相反的症状:默认值等于正确答案时它隐形,不等时它诬告产物。** + → **修法不是加守卫,是从数据本身读:** m4 从每条运行**自己的** `environment.yaml` + 里读 `tp_size`,而不是假设。 +- **一个已知good的对照,抓住了工具自己抓不住的缺陷——而对照是偶然有的。** + (m4,同一份报告,今天关于「先跑一个已知答案」最强的论据。) + 他们的 store 记录写 `version: 0, status: valid`,而 **`v0/content/` 是空目录, + 内容在 `v1/`**。**空目录是格式良好的输入**,所以三个 validator 都正常跑完、 + 写了 verdict、以「文件缺失」驳回——**四条自信的假驳回。** + > **「我的工具里没有任何东西发现它——是它和 m2 已有的 PASS 矛盾才暴露的。」** + **那个对照只是因为我恰好告诉过他 m2 已经判过那一份。没有它,四个假驳回会作为 + 发现被报上来。** + → **建一个新的评分/审计工具时,第一件事是让它评一个你已经知道答案的样本。** + *而这个 `v0` 缺陷今早就已经写在这个文件里了(m5 一小时内撞过两次),它没有到达 + m4——**分发失败,不是警觉失败**,正是 m4 自己四小时前给我的诊断。* +- **缺席比在场被优待——供给那个字段才把你暴露给检查,省掉它买到一次干净通过。** + (m5,2026-09-05,写注释时才想清楚的那句。) + ```python + if not operator_id or op.get("operator_id") == operator_id + ``` + **manifest 没有 `operator_id` → 静默匹配 workset 里第一个算子, + 报告一个「干净的集成点」——一个从没和任何东西核对过的集成点。** + **manifest 有 `operator_id` 而且不一致 → `_DISAGREES`。** + > **这不是「缺席不可见」,是「缺席被奖励」。检查在惩罚完整性。** + *他们自己回放的那份产物就是这么「找到」一个的;m1 的 manifest 恰好带了那个字段, + 是这次不一致能被看见的唯一原因。* + *可存活的细节也记了:`check_apply_manifest` 确实会报这个字段缺失, + 但 `bad` 在这次调用之前算、在之后抛——**所以这一条先跑,读者看到的是它的答案。*** +- **核查自我更正,和核查自我指控,不是一回事。** + (今天两条相反的教训,需要一起读才成立。) + - **m3**:一句自责什么审查都得不到,因为怀疑它看起来像替人开脱——**那是一个洞。** + - **我**:我采纳了 m1 一次**错的撤回**并把它归档,正是因为它是自我更正。 + - **m5**:*「核查同事对自己条目的更正,就是『verify everything』变成『不信任任何人』 + 的地方——而今天之所以成立,靠的正是人们公开地更正自己。」* + **分界线:一次自我更正如果**做出了一个可廉价核查的事实主张**(m1 那次说的是 + 「那行代码是有意的降级」——一次代码阅读),就核它;如果它只是**编辑自己的记录**, + 不要回审。**前者是新主张,后者是收拾自己的账。** +- **不承重的细节,是逃过审查的那一类——而它传播得和承重的一样远。** + (m3,2026-09-05,诊断自己把一个**被撤回**的说法写成了「如你所确立」。) + 他们当时人在场,看着那次撤回。**所以这不是信息过期,是他们在写一段 + 「我的实验证明了什么、没证明什么」的段落时,顺手把一个已撤回的说法升级成了确立。** + 他们自己的判据: + > **该抓住的信号是,我伸手去拿它,是当作**佐证细节**——它在给我的时间线收拾 + > 一个线头,不承担任何重量。而不承重的地方,正是我停止核查的地方。** + **同一个容器,同一个下午,三次误判:我两次从「相邻」推归属(一次卡号、一次时间), + 他们一次从「撤回」推确立。共同形状是——三次在做出的那一刻都不承重。** + → **这是第五种豁免。** 前四种是:印证性的结果、无人反对的说法、转述来的发现、 + 自我指控。**这一种最难防,因为前四种至少还是主张;这一种连主张都不算。** + *m3 的补救形式值得抄:把结论真正依赖的三条第一手证据逐条列出, + **那个容器在那条链里一次都不出现**——于是「它是装饰」这件事本身成了可查的。* +- **一个人主动认领的过错,是今天所有防御里唯一没人去核的一类。** + (m3,2026-09-05,他们自己撤回了自己的自我指控。) + 他们告诉我那个 inert flag 影响了自己五次发车、「错的答案和对的答案是同一个数字」。 + 后来一查:**`GPUS=` 在整个 `assets/` 只出现一次,有默认、无人设置, + 而他们自己的阶段读的是 `E2E_MEASURE_GPU`——那个机制从来不经过他们的代码。** + 他们把同事的机制套到了自己的代码上,没有先查自己的代码读不读那个字段。 + **他们自己的诊断比这更尖:** + > **我主动认领了这个过错,这让它更不容易被质疑,而不是更容易。** + **这是这套体系的一个洞。** 我们已经会核「印证性的结果」「无人反对的说法」 + 「转述来的发现」——**而一句自责什么审查都得不到,因为怀疑它看起来像在替人开脱。 + 我当时也没核,正是这个原因。** + + **m3 的补充,说明这一类为什么更顽固:这个豁免是社交性的,不是认知性的。** + 印证性的结果、无人反对的说法,是**注意力**没到那儿而漏掉的; + **一句自责漏掉,是因为核它看起来像不厚道——审查者必须主动去反驳一个人对 + 自己的指控。** 前者靠提醒能修,后者要付人情成本。 + + **收口(m3,2026-09-05 傍晚,把这一条和它的反面接上了): + 「主动交出对自己不利的那一半」正是今天能成事的机制——而它恰好也是 + 唯一没人去核的那一类。** 原话: + > **它依赖的是「在没人会去查的时候,主动说出更弱的那个说法」。而我今天在 + > 反方向上犯过一次:我主动认领了 `gpu_devices` 那个 flag 的过错,而它其实 + > 不适用于我的阶段——它没有被审视,恰恰因为它对我不利。** + > **所以这个习惯有用,但它不能替代那次核查。** + **同一个习惯,既是这套协作可信的原因,也是唯一一类不被核查的主张的来源。** +- **一次升级里捆着两个主张——机制和时钟——它们可以可靠性不同,却以同样的自信抵达。** + (m3,同一小时,自己举证。)机制是对的(`reset_gpus.sh` 确实是节点级), + **时钟是错的**:把 `mock_stages=m2,m3,m4` 读成前段全 mock,于是「几分钟内」, + 实际是 35 分钟。**两个主张一起送到,语气一样。** + *如果他们肯自己对别人的线动手,就会杀掉一条还有半小时余量的运行。* + **推论:一封「快来不及了」的升级,机制和时限要分开标注置信度; + 收到的人先核时限——因为它决定你还有没有时间去核机制。** +- **「从没开始」的检查,覆盖不了「已经做完并停住」。**(m2,2026-09-05, + 为今天最贵的那个缺陷设计探针时说的。) + m4 的判据是「`forge/` 会不会在五分钟内出现」——它抓一个**从没启动**的阶段。 + 而赔掉 hold `112699` 的是一个**做完了**的阶段:`forge/` 在、phase 行写着 + `running`、节点安静——**这三条对「一个正在思考的健康 agent」同样成立。** + **分开它们的是 transcript**:干活的 agent 持续写,做完的戛然而止。 + 这就是 `ep_poll`、17 个空闲线程那个尸检的直接签名,不需要任何推断。 + 阈值 7 分钟,对照当天观察到的最长健康间隔约 4 分钟。 + **而 m2 主动写明它不做什么,这才是可信的原因:它只探测,不修复。** + 一旦响了,阶段已经完成而运行仍不会前进,**唯一已知的补救是拆除,而拆除会丢掉 + 该阶段下游的一切——所以一次命中是「去把 agent 的报告捞出来」的指令,不是恢复。** +- **同名不同根的树:第三个实例,而且这次两棵都是真的。**(m3 的归类。) + 前两次是「一个名字指向空」和「一个名字指向两个标题」; + **这次那个名字指向一棵活着的树——只是不是那棵活着的树。** + `/home/yihou/e2e-flow-noval-093` vs + `/home/yihou/dev/git/…/e2e-flow-noval-093`,后者才是 093 链 stage 的那棵。 + **我按名字相似挑了测试目标,`rc=0`,差一条消息就去告诉别人那个守卫失效了。** + *而那棵 16:08 建的多余树删除被权限拒绝,所以它会以诱饵的形式活过今天。* +- **一句汇总数字扫一眼无法证伪,一张表可以——所以交表,不要交那句话。** + (m1,2026-09-05,自纠一个说过两遍的数,而且赶在它进 `WHAT-GREEN-ESTABLISHES` 之前。) + 他们说「21 个 kind 里 18 个完全严格」。**21 是 validator 数,不是 kind 数; + 实际是 15 个 kind 里 12 个。** 两个数被混成一个,然后被复述。 + **「18 of 21」读起来完全合理,而且没有任何东西可供核对;那张十五行的表格 + 每一行都能被反驳。** + 和「计数要分 kind 引用」同族,但更进一步:**问题不是那个数错了,是那个形式 + 不邀请核对。** 他们自己的话:*「我宁可交给你一张表,因为我搞错的正是那句话。」* + **推论:凡是要进文档的汇总数字,连同它的分解一起交。** +- **一个状态字段说的是「当前状态为什么成立」,不是「接下来会怎样」。** + (m3 给的形式,而当天下午我就被它打脸,证据是第一手的。) + 我从 `112799 PENDING (QOSGrpNodeLimit)` 推出「217 一掉,112799 就起来」, + 并把这个预期发给了两个人(标注过「报实际不报预期」,但推理还是发出去了)。 + **实际发生的:217 的 hold 16:01 过期,`112799` 至今仍 PENDING, + 而一个全新的 `112862` 在 16:01:33 提交、16:01:34 启动,拿走了那个槽。** + **`QOSGrpNodeLimit` 告诉你它为什么在排队,不告诉你约束解除时会发生什么。** + 同族:`squeue` 的 `TIME_LEFT` 是**上界不是时刻表**——我预测那堵墙预测了一小时。 +- **第六种「卡是空的」:占用量的是**现在**,而一条活着的链占的是**将来**。** + (2026-09-05 15:58,m1 报「093 完全空闲——八张卡,还有 6h20」,而**同一时刻 + 两条全真链正跑在 093 上**。) + 我去 `rocm-smi` 核了:**cards 0-3 VRAM 0%。m1 的测量完全正确。** + 但那两条链一条在 `build_workset`、一条在 `identify`——**都是 CPU 阶段。 + 卡此刻真的空,而它们回到 GPU 阶段时就要用。** + **和前五种都不同**:声明/意图/读数过期/孤儿容器/活 agent,那五种里读数本身是错的 + 或会过期。**这一种读数对、而且还会对一阵子——错的是从「空」推出「可用」。** + > **判据:先问「这台机器上有没有活着的链」,再问「卡现在忙不忙」。 + > 两个都要问,而第一个先问。** `lines.sh` 回答第一个,`rocm-smi` 回答第二个。 + + **为什么第一个在前(m3,比我那版准):不是「进程表更好」,而是 + **问题 1 能否决,问题 2 不能**。而且两者互不包含,所以谁都不能省:** + - **进程表看不见**「没有编排的容器」——活过自己 run 的 agent、重启的孤儿,今天都实测到过; + - **读卡看不见**「已发车但还没 bring-up 的链」,也**完全看不见一个 CPU 阶段**。 + + **修正(m3,2026-09-05 傍晚,他们自己降级了自己那条):发车前读卡不是保护, + 是对其他租户的礼貌。** 它今天**两次「正确且无用」**——16:03 在 217 读到八张 + baseline,`glm53-work` 16:40 才来;14:59 在 237 读到八张 baseline,一个容器 + 60 秒后重新加载。**两次真正保住运行的都是 `deploy.sh` 的前置检查:它在 + bring-up 那一刻读,并且带着数字拒绝。** + > **它告诉你「你会不会撞到别人」,不告诉你「你的运行能不能活下来」。** + **问题 1 原样保留**——进程表能否决,而且任何代码级前置检查都替代不了它, + 因为它回答的是**别人的意图**,不是硬件的状态。 + *我原来把问题 2 写成了保障,那对它太客气了。* + + m3 拒绝了我给的台阶(「这条你本来也抓不到」),理由值得留着: + ***工具我有。`lines.sh` 回答的就是问题 1,而我今天早些时候把它当占用检查用过—— + 正是它被改名要防的那个错。所以第六种不是够不着,是我把进程表归类成了读卡的备份, + 而不是前置问题。*** + *代价:差点把 093 的卡派给 m3 的 NCCL 实验,而那台机器上跑着今天推进最远的两条链。* +- **为了让比较可做而自己挑的那个边界,必须和结果一起说出来。** + (m4,2026-09-05,一次调查里同形犯了两次,而且**第一次是他们自己命名的**。) + - `find -maxdepth 4`,文件在深度 7 → 「原生输出不存在」。 + - 剥掉 399 字符的 header 去比 body → 「header 声称的出处,body 没有」。 + **而那段 header 写到第 13 行**,第 8–13 行正好说明这是三段式合成文件。 + **两次都不是仪器骗人,是我自己设了一道界,然后报告界内的结果、不报告那道界。** + 「剥掉 399 字符后 body 大了 5.2 KB」这句话是真的,**它同时静默断言了 + 「header 是 399 字符」**——而那一句才是错的。 + **可机械化的形式:凡是报告一次带截断/深度/范围的比较,把那个界写进结论那句话里。** + m4 自己的注脚最狠:**第一次他一小时前就诊断并命名了,命名没能阻止他在同一次 + 调查里重犯——这正是「只有第三类会衰减」,而且是一条关于第一类规则的第三类规则。** +- **「先别提交」不是一道闸门——包 stage 的是工作树,不是 HEAD。** + (m4,2026-09-05,他们纠正的是我下的指令。) + 我说「你先做,落地由我定」,而我心里的「落地」是 `git commit`。 + **一次运行在每个任务开始时 stage 工作树,所以一处未提交的改动会到达此后开始的 + 每一个任务,全程没有 commit 参与。** 照我的字面执行,就是先改树、再等一个 + 已经迟到的裁决。他们把补丁放在 `/tmp` 里——**那是在纠正指令,不是在服从它。** + **闸门必须落在「改」之前,不是「提交」之前。** + *和「杀编排不等于停运行」同族:我以为的那个控制点,不是系统真正的控制点。* + **换掉那句话本身(m4:「落地」对人是 commit,对框架是 edit)—— + 不要说「先别提交,落地我来定」,要说「在我点头之前把它放在树外」。** + **补丁待命的位置写成 `/tmp/…`,不是工作树里的一个分支或一份 `.orig`。** + *下一个被告知「你先做,落地我定」的人,会照字面执行。* +- **一个全绿的 premise gate 保护不了一次比较,因为决定答案的那个变量不在它的字段里。** + (m4 的 agent,2026-09-05,而**造成它的是我**。) + 它比的是 `gpu_arch / gpu_count / tp_size / dtype` —— 四个静态字段,全绿。 + **而决定这次结论的是「机器忙不忙」,gate 看不见。** 同期 stock 重测比记录时慢 + **7.1%**,因为相邻四张卡上有另一条线在跑。 + **那条线是 m1 的,在 217 的 4-7 上,而「一台机器两条线」是我按规矩 5 安排的。** + → 规矩 5 没错,但它有一个**可测量的代价,而且恰好落在要出数字的阶段上**。 + **凡是产出「测量」的阶段,前置条件里必须有一条「节点安静」,而 premise gate + 给不了它。** 这是 2026-09-02 遗留的那个未决问题第三次独立复现。 + **同族:`_agree_or_die` 覆盖 28 个字段里的 3 个。守卫覆盖的是有人想到的字段。** +- **它拒绝声称加速,而这是今天最该被学走的一次「产出」。** 同一个 agent: + 对 workset 记录的 baseline 是 **0.9883**(慢 1.2%);对同期 stock 是 **1.0587**, + 与 forge 自报的 1.0748 互相印证。**两个读数都远低于 workset 声明的 + `noise_floor: 1.1729`,于是它整块删掉了 `claim`,两张测量表都留着。** + 它自己的话:*「我删的是断言,不是数字。」* + **对照本文件里那些「值的形状 vs 值的意义」的教训:这是同一枚硬币的正面—— + 数字齐全、门槛在手、结论是「不声称」。** +- **两份拷贝大小不同,可能是量具的单位不同,而不是数据缺了。**(m2,2026-09-05, + 抢救 217 时。)`du` 报 215 M / 186 M,差 30 M,看起来像漏拷。**文件数两边都是 + 12,857,一个不差。** 差在 12k 个小文件在本地盘和 NFS 上的块分配不同—— + **`du` 量的是块,不是数据。** + **判据是文件数,不是 `du`。** 和本节其余各条同形:仪器没坏,它在回答另一个问题。 + *他们的 `RESCUE.txt` 还把 `GENERATED.txt` 那条教训搬了过来:`cp -a` 保留源 mtime, + 所以整棵副本里每个文件的时间戳都描述原件,**唯一描述「拷贝这个动作」的就是那一行 + 读出来的 `rescued_at`。*** +- **一个「曾经为真」的值,可以经由**指令**抵达,而不只是经由工具或记忆。** 我让 + m4 把 217 上测的 13.3% 写进 287 的产物。工具和记忆都有防御(重测、重读、查 + 分母),**协调者的指令没有——它抵达时已自带授权**。owner 必须像核查工具一样 + 核查交给他的指令。 +- **一次笼统的暂停,对下令的人是免费的,代价落在那些本不需要它的条目上——而两边 + 都不会察觉,因为什么都没有失败。**(m3,2026-09-05,他们把它认成和今天一整天 + 在抓的守卫同一个形状:**一个保守的默认值,在它错的时候是沉默的。**) + 我对 m3 说了四次「等树安静」。三件配得上——都改变行为,而包按任务重新 stage。 + **第四件是一段 markdown,我默认扔进了同一个桶。** 说出口的时候没有成本, + 被押后的恰好是等待期间最值得动的那些。 + m3 自己的补刀:*「你纠正它是有用的那一半;我那半只是没有开口问。」* + **推论:凡是给出一条笼统的限制,同时说出它不适用于什么。** 未加限定的限制会 + 一直扩张到没人反对为止,而**「没人反对」在今天这份文件里已经等于「没人检查」**。 +- **一个读数可以在被取的那一刻正确、在被用的那一刻错误——而 git 里也一样。** + 2026-09-05 巡检:`git status` 报 `M kernel_taxonomy.yaml`,二十秒后 `git diff` + 为空。我推成「写过又还原」并写进了给用户的汇报。**真相是 m3 在这中间提交了 + `f69ae83`——文件没变,HEAD 变了。** + 我这一轮**开头就跑过 `git log`**,它当时完全正确,二十秒后过期。 + **和「卡在被读时是空的」是同一个形状,只是从集群搬到了仓库里。** + 判据是廉价的:`M` 后面跟一个空 diff 有两个解释(还原 / 提交), + **区分它们的是一次 `git log`,而且必须在观察之后取,不是之前。** + + **两条的公共形式(m3,而且它同时管住上面那条卡):把读取移到落子点,不是决策点。** + 「发车前重读卡」和「观察后再取 log」是同一条指令。**两者都不封闭那个窗口, + 但都把它缩到你真正控制得住的最小一段。** + m3 的对照也值得留着:*我这条便宜,只因为判据存在而且是一条命令;他们 14:59 那次 + 读卡没有等价物——这正是第五种失效至今未被覆盖的原因。* +- **三个人对一个 flag「意图」的一致,不等于一次对它「效果」的测量。** + `graph_ceiling.py:169` 的注释记录了三条佐证——m1 的注释、m5 读 `start_worker.sh`、 + 两个源文件。**三条都是「这个 flag 应该是什么意思」的阅读,没有一条观察过引擎。** + 而 m4 的 kit 同时传 `--cuda-graph-backend-decode full`,新配置路径在 decode 上 + 胜出,**那个 flag 被静默推翻**——于是这个判据恰好在「decode 图真的在跑」的运行上弃权。 + **来源独立不等于方法独立。** 三个人读同一类证据,只是三次同样的阅读。 + **tier 2 形式:一条断言「某 flag 有何效果」的注释,必须引用一次观察(运行、日志), + 不能只引用源码阅读——「它引的是哪一类」是可查的。** + **2026-09-05 我自己犯了同一条,而且是在庆祝一个「对照」的时候。** 我说 + 「工作集里写的 `base_sha256` 和我 `docker run … sha256sum` 量的一致,是两个 + 独立来源吻合」。m3 纠正:**只有当他们的生产者是「量」而不是「抄」时才独立—— + 它确实是量的,但 `identify` 的 `image_facts` 跑的也是镜像里的 `sha256sum`。** + 所以那是**同一个方法的两次独立执行,不是两个方法**。 + **它能抓住「抄来的值」和「过期的值」;抓不住「两边都哈希了错的文件」。** + 推论:说「两个来源吻合」之前,先问**它们用的是不是同一个方法**。 +- **一致性检查通过时,先问它比的两个值是从哪来的。**(readme-cn,2026-09-05, + 给 `_agree_or_die` 写的判据,但形状是通用的。) + > 两个**独立**来源 → 这次通过是有信息的。 + > **一个来源被拆成两份** → 它只告诉你复制成功了。 + 三条把一个来源拆成两份的路径,今天各出现一次:**继承**(下游全部 + `env_render --inherit` 同一份 kit)、**铸造**(记录在源头就写错)、**自引用** + (我把 `premise.run_environment` 设成 workset 自己的环境)。 + **这是「所有比较方都共享的错误看不见」那条规则的极限情形——「所有方」退化成一方。** + 通过仍然返回 true,但它**排除不掉任何一种世界状态**。 + **第四条路径,而且它落在这份文件自己列为「被守卫」的字段上**(m2,2026-09-06): + `slurm_jobid` 是 `_agree_or_die` 守的三个之一,而一个指向**已经结束的 hold** 的 + jobid 照样通过,因为守卫比的两边都来自同一个 `--var jobid=`: + ```sh + measure_in_container.sh:118 + _agree_or_die() { # name ambient from_record + if [ -n "$2" ] && [ -n "$3" ] && [ "$2" != "$3" ]; then # 只在「不一致」时拒绝 + ``` + **陈旧的值和它自己一致,于是守卫满足。守卫问的是「两个来源吵不吵架」,不是 + 「这个值是不是真的」——一个字段可以被守卫,并且整体地、一致地错。** + jobid 会被封进每一份产物的 `runtime.slurm_jobid`,而 `show` 分不出死活 + (两者都是合法字符串)。**比 `produced_by.commit` 更糟,不是它的兄弟: + `unknown` 在自报无知,陈旧的 jobid 什么都不喊。** + 判据是发车前断言它 `RUNNING`,不是断言它非空。 +- **观察者会写进它所观察的产物里。** m2 用 `worker.log` 的 mtime 当活性信号, + 而他自己每次 `health_generate` 探测都会在里面写一行「Health check failed」—— + **mtime 追踪的是轮询器,不是引擎。** 引擎最后一次真实工作停在 09:47:39, + 而 mtime 一直在动。**修法是 tier 1:这个轮询只读 `up=` 和 `traces=`,不读 mtime。** + 一般形式:**一个会写进产物的探测,使那个产物不能再当活性信号用。** +- **今天两个人各自靠「和已知矛盾」抓住了一件坏掉的仪器,而两个人都说了同一句: + 那不是一道防线。**(m4 与 m5,2026-09-05。) + - **m4**:`v0/content/` 是空的,三个 validator 正常跑完并以「缺文件」驳回—— + **四条自信的假驳回**。*「我的工具里没有任何东西发现它——是它和 m2 已有的 + PASS 矛盾才暴露的。」* + - **m5**:`$R` 是 zsh 变量,在 `bash -c` 里被写成 `\$R`,bash 展开了一个未设的名字, + 于是每条路径都不存在——**一个来自不存在路径的干净的零**,而且方向恰好相反 + (报的是「两个 IP 都零命中」)。*「只因为它和我几分钟前读过的一份 + `deployment.json` 矛盾才被抓住——这是今天唯一起过作用的防线, + 而它根本不是一道防线。」* + **这一条本来就在下面。今天的新东西是:两个人独立撞上它,两个人都自己指出 + 「靠矛盾抓住」不可依赖,而两次的对照都是偶然存在的。** + → **可依赖的那一版是 m4 给的:先让新仪器去评一个你已经知道答案的样本。** +- **坏掉的仪器,只有在它和你已知的事情矛盾时才会被抓住。** m3 抓住 `find` 的空 + 结果,不是因为谨慎,是因为两条命令之前他刚 `ls` 过那个目录。**如果那条命令是 + 他跑的第一条,他会信。这种情况至今没有任何防御。** 推论:一个工具给出的 + **第一个**结果,和一个**确证**你已有判断的结果,是最不该信的两种。 +- **派活时明说「如果那行是工具的假象,就停下来告诉我」。** 这一句比工具本身值钱: + 它给了人「交回空结果」的许可,四个 owner 因此没有改动任何一个 validator。 +- **通过的那一侧往往更危险。** 一个被剥掉阈值的 validator 是**平凡通过**的, + 所以 26 个 PASS 是最弱的证据,不是最强的。 + **同一形状会出现在审计上**:一个只做正向遍历的检查,面对「缺席型」缺陷不是 + 沉默,而是**报告文件健全**——十三行准确、零分歧、结果干净,而要紧的四行 + **从来不在被检查的总体里**。 +- **一个结果如果印证了你此刻正在支持的东西,你去核它的概率会下降。** 三个实例、 + 两个人、同一方向:checkpoint 的「连续七个生产者都选了 8」讨好了他正在审视的 + 改动;他的「这个检查在首次应用时抓住了我」讨好了他正在背书的检查;**而我把第二条 + 放大给全队和用户,讨好了我刚采纳的机制——我没有核它。** + **操作准则:印证性的结果要按驳斥性结果的标准去核,尤其当它印证的是你刚写下的东西。** + **推广(checkpoint 08:20):不只是「印证性的」,是「无人反对的」。** 他往 bug + 记录里写了九个「最后核对」时间戳,**一个都不是读出来的**——07:40 看了一次钟, + 之后全靠外推,最离谱的一个**快 66 分钟、落在未来**。而他是**在同一个文件的 + 页眉里采纳「凡会过期的都要标日期」这条约定时**写下它们的。 + 他自己的诊断:*「我全程手边就有一只钟,一条 `date -u` 的距离,而我没跑, + 因为没有任何东西在质疑那个数。」* **无人质疑的说法,享有和印证性说法同样的豁免。** + 补上的那一条:**日期本身必须是读出来的;一个编造的时间戳比没有时间戳更糟, + 因为它让「已核对」看起来是可查的。** + +- **共存租户会污染一次失败,不会污染一次成功——所以判读必须是不对称的。** + (m3,2026-09-06 04:4x,`t551` 落到他们正在用的四张卡上之后。) + 观测量是 `ncclCommInitRank` 完不完成,不是跑多快: + > **3/3 通过 → 比率成立,共存租户反而是加分;任何一次失败 → 那次试验不可判读, + > 我会明说,而不是把它记到镜像头上。** + **理由是那次失败会多出一个活的第三解释,而那正是 237 留下的处境: + 一次无法归因的失败。** + *第二重守卫不需要他们争取,是机制给的:`deploy.sh` 的前置检查有每卡 200 GiB 下限。 + 实测 37 GB 已用、约 250 GB 空闲,通过;**若扫描涨过约 88 GB/卡,下一次试验会在 + bring-up 之前大声拒绝**——今天 16:05 就这么拒过一次。**是机制在决定,不是人。*** +- **一条会走到真实阶段 5 的链,在有外来负载的节点上,必须把「停在阶段 2」当成安全 + 要求而不是排程便利。** `reset_gpus.sh` 是节点级 `kill -9`,只保护 `slurmstepd`。 + **区别在于:「我本来打算停在阶段 2」会在一次快跑完、下一步看起来很便宜的时候消失; + 「停在阶段 2 是安全要求」不会。** 这正是 15:07 m5 的 agent 拒绝执行它的那个情形。 + +- **一个哨兵值恰好越过了守卫,而差额是一个字符。**(m3,2026-09-06 05:0x, + 修 `produced_by.commit` 时顺手发现的。) + schema 对该字段有 `minLength: 7`,**而 `unknown` 正好是 7 个字符**。 + > **守卫是为了挡住「太短的值」而设的,而那个占位符按一个字符的余量通过了它。** + 和「值的形状 vs 值的意义」同族:**长度约束挡不住一个长度合法的谎。** +- **用「重新序列化」去改一个共享文件,会顺手改掉别人的行——语义相同,diff 不同。** + (同上,m3 自己捕获并回滚。)他们第一版用 + `json.dumps(..., ensure_ascii=False)` 写回 schema,**把别人描述里三处 + `\u2014` 转义静默变成了字面 em-dash**:JSON 语义完全相同,**三行不属于自己的 diff, + 在一个四个人都在编辑的文件里。** + `git checkout` 回滚后改成定点文本编辑,最终 diff 2+/1−(那个 1 是一个逗号)。 + > **这就是「重建 vs 追加」那条的又一实例,而他们是在对别的每件事都很小心的时候撞上的。** + +- **三种豁免可以同时出现在一句话里,而那正是最危险的形状。** + (m3 的归纳,2026-09-06 05:1x,归纳的是我。) + 我给 `buckets:` 设的条件是「第一条绿链之后」,m3 问今晚算不算,而我的回答是: + > **「条件是我设的,东西是我想要的,而开口是你给我的。」** + **印证性的答案 + 无人反对的来源 + 决定者本人受益——今晚一直在分别记录的三种豁免, + 同时成立。** 诚实的读数是:最好的一块板是 16/17,带一个结构性拒绝, + 而 `packup` 每一轮都没达到——**没有任何一条链走完五个阶段。所以答案是不。** + *而 m3 拒绝把「解除了一个条件」当成「解除了另一个条件」,是这件事被摆到台面上的原因: + 我把三项一起说成解封,而 `buckets:` 从来不是卡在树上。* + +- **第七种「卡是空的」:容器在场但空闲——它可以在任何一刻开始加载,而两分钟就够。** + (2026-09-06 06:18–06:23,我读 0%,m1 两分钟后读 34% 并复现两次。) + 两个读数**可能都是对的**:`glm53-work` 02:35 就起来了、一直在那儿,而 + `rocm-smi` 在它开始载模型之前当然读 0。**一次冷加载两到四分钟。** + > **所以「卡空」的保质期,在一个在场但空闲的容器面前,是分钟级的; + > 而我又一次拿它当发车许可发给了别人——今晚第二次(第一次是把 093 给 m2)。** + **判据便宜:读卡的同时读容器清单。零容器的零和「有容器但此刻不占卡」的零, + 是两个不同的零,而只有前者能当许可。** + *m1 的处置值得抄:他们没有说我错,而是说「决定能不能放整机轮次的那个读数, + 此刻是 34%,而且按容器自己的启动时间已经四小时了」——把主张限定在决策所需的那一句上。* + +- **一件仪器可以在所有「不能暴露它」的情形里都与真相一致,而在第一个能暴露它的情形里 + 给出错的答案——于是它让我撤回了一个正确的发现。** + (2026-09-06 07:2x–07:3x,我干的,m1 纠正。) + zone 目录叫 `validation.<任务ID>.<阶段>.<哈希>`,**任务 ID 就在 zone 自己的名字里**。 + 我 glob 到 zone 后取 `dirname`,读的是**外层**任务: + ``` + zone 属于 m2_profiling 内部的叶子 → 外层就是 m2_profiling → 两法一致(前五次) + zone 属于 m2_profiling 自己 → 外层是 main → 只有这一次会分叉 + ``` + **前五次全对,而且看起来无懈可击;第六次一分叉,我就撤回了那个正确的结论。** + > **和「我的样本在结构上不可能给我反例」同形,只是低了一层——在工具里。** + **而撤回本身没有被核:这份文件里已经两次写着「撤回是一个指向反方向的主张, + 需要同等核查」,其中一条是我自己写的。** + *m1 那句更尖:那个任务 ID **印在我每一份报告里、就在我反复粘贴的字符串内部**, + 六次阅读、两个人,都读了旁边那个计数。**不是「差一次 `store/task` 读取」,是零次。** + 修法是 tier-1:让工具直接打印 `closure=`,而不是要求谁记得去查。* + +- **一个后台作业会被「启动它的那次工具调用超时」连同进程组一起收走,而现场和挂死 + 一模一样。**(m1,2026-09-06 08:0x,今晚最值得带走的运维发现。) + 他们的 mock 回路看起来在 `identify: input_validating` 卡了十分钟——编排进程消失、 + 没有任何 escalation、**日志干干净净地结束、没有错误**。 + **它没有卡:store 里最后一个事件是 `+104s`,正好落在那次工具调用两分钟超时的那一秒。** + > **`nohup` 挡不住这个;`setsid`(放进自己的进程组)可以。改用 `setsid` 重发之后, + > 它径直走过了「卡住」的那一点。** + **分辨它的是时间戳算术,不是日志。** 这是「运行看起来死掉」的第四种方式, + 和前三种(stall detector、agent 扫描、撞墙)都不同——**是 harness 在回收。** + *而今晚我起的每一条长运行也都是 `nohup … &`,只是那些调用恰好返回得快。* +- **推翻你的那次检查,可能比说服你的那次更弱,而没有任何东西会提示这一点。** + (m1 给我 2026-09-06 07:3x 那次错误撤回的诊断,今天第三次「撤回本身是错的」。) + 我用一次**更间接**的读法(目录嵌套)推翻了一个用更直接的方法刚确立的发现, + 几分钟之内。**不是「不该核查」,而是核查的强度没有被比较过。** + > **一次撤回要问的第一个问题不是「新证据说什么」,而是「新证据比旧证据强还是弱」。** + *而真正的判据两边都不是:zone 自己的 `inputs.json` 里列着 handoff,那个 handoff 有 + kind——**它从头到尾没有歧义,而我们俩都在推断身份。** 修法是 tier-1:让工具直接 + 打印 `kind=` 和被 stage 的版本,身份不必再被推断。* + +- **一个算出来的值进了**报告**(而不是破坏性命令),照样会杀人——它产出的是 + 「对一个正确发现的假反例」。**(m1,2026-09-06 12:2x,自己举证。) + ``` + R=$(ls -1td .../runs/*/ | head -1) # 「最新的运行目录」 + # 而当时并行跑着两条回路:另一条的 run 在中途开始 + # 于是这条结束时,「最新」已经是别人的了 + ``` + **它把另一条回路的「零空」报成了自己这次的结果。** 有二十分钟,那个故障看起来 + 「在固定配置下是随机的」——**而那几乎重新坐实了我半小时前那次错误的撤回。** + > **和「空变量落进破坏性通配符」同族,高了一层:那一类毁掉别人的工作, + > 这一类毁掉一个正确的结论。而后者没有任何东西会报错。** + **抓住它的是「重新测量」而不是「重新阅读」**——直接从每次运行的 `inputs.json` + 逐个枚举。**摘要和真相不一致,而只有直接测量是承重的。** + > **推论:自己写的摘要不是证据。** 今天这已经计费三次(我的 zone 解析器、 + > 他们的回路摘要、以及那个把身份概括成计数的报告)。 +- **同一个 kind 在一次运行里可以有多个 zone,而只有一个是坏的。** + `profiling_evidence` 每轮出现在**三个** zone(两个 output_validation、 + 一个 input_validation),**只有一个是空的**。 + **任何不说明「哪一个」的报告都是不可证伪的**——我们两个人的工具都曾如此。 + +- **第八种「卡是空的」:没有任何容器,而占用正在爬升。** + (m1,2026-09-07 05:1x,把前一天那条第七种又推进了一步。) + 020 上零个运行中的容器,八张卡 15–16%;**四十分钟后 87–88%,始终没有容器出现。** + `rocm-smi --showpids` 显示**六个非 docker 进程,各约 49 GB**。 + > **一台没有容器可解释的机器上读到 15%,意思是「一个负载正在到来」, + > 不是「一台还有空间的机器」。** + **按「还剩 85%,一台 TP4 塞得下」的算术发车,会让两边的加载撞在同一个窗口里。** + *第七种是「容器在场但空闲,随时可能开始加载」;这一种连容器都没有。 + 共同的判据仍然是那一条:**读卡的同时读容器清单,而「零容器却非零占用」是最坏的组合**—— + 它既不能被 `docker ps` 解释,也不会停在原地。* +- **规矩 1 对「不在容器里的 GPU 进程」没有任何手段,而这是它的边界而不是漏洞。** + 它说的是 `docker stop -t 10`,**而这里没有容器可停;杀别人的裸进程远超它的授权。** + m1 的处置是对的:**上报、放着,并把「没有可安全运行的东西」记为一个决定, + 而不是记为「没有做决定」。** 他们自己的 precondition(八卡必须读零)也会拒绝它—— + **那是正确答案,不是障碍。** + +- **一个存在且几乎为空的目录,和一个挂载点,`[ -d ]` 分不出来——只有 `df` 能。** + (m1,2026-09-07,在一台八卡全空、hold 还剩 7h50 的节点上,发现我们的任何配置都跑不了。) + ``` + 坏 df /shared_nfs -> /dev/vda1 123G / 挂载点在,文件系统没挂上 + 好 df /shared_nfs -> 172.27.255.2:/volumes/… 360T + ``` + **`[ -d /shared_nfs ]` 两种情况都返回 true**,而 m1 的第一次检查正是这么写的, + **差点报成「这台没问题」。** + **一旦它失败,三样输入同时消失**:镜像 tar、模型权重、语料——**所以连一次全 mock + 的运行都读不到自己的输入。** 不是「这台需要先 load 镜像」,是**这台上我们没有任何 + 配置能跑**。 + > **而失败信息点名的是文件,不是文件系统**:`docker load` 报 tar 的 + > *no such file or directory*,读起来像「有人把它删了」,直到你去看 `df`。 + **和本节其余各条同族:仪器在回答另一个问题——`[ -d ]` 回答「有没有这个目录」, + 而你要问的是「那个卷挂上了没有」。** + +## 零和缺席 + +- **一个由「偶然撞见的实例」算出来的发生率,分母从没被数过——它不能用来推翻测量。** + (m2,2026-09-06 00:1x,而且这是他们**对自己结果**提的告诫,不是对别人的。) + 「11–13 个 zone 命中 1 个」来自三个**偶然**发现的实例。m2 采了 **80 个 staged + handoff / 60 个 zone,零个空**,两件独立仪器一致。 + **诚实的读法不是「mock 路径看不见它」,而是「那个发生率本身不可靠」**—— + 因为它的分母从来没有人数过。**「先测分母」在这里换了个位置:要测的是那个 + 被用来做对照的估计值的分母。** + + **而真正值钱的不是那个零,是「前提很常见、而且每次都被正确处理」:** + ``` + 多版本、且存在一个空版本 → staged v1,非空 20 次 + 单版本、没有空版本 → staged v0,非空 60 次 + ``` + **100 个 handoff 里 30 个带着真实实例那个 `v0=0, v1=N` 的形状,被 stage 的 20 个 + 全部选中了有内容的那个版本。** 于是最自然的那个假设死了——**不是「空 `v0` 让 + staging 犯迷糊」,那种情形一直在发生而且处理正确。** + *这同时用 80 个新样本独立印证了 m1 对 `v0` 说法的推翻:60 个 zone 从 `v0` stage, + 个个有内容。* +- **一个「零」到底是「没记录」还是「没发生」,不跑控制组分不出来。** + 踩过三次:`zone: NONE`(成功的任务也是 NONE,该字段根本不存)、 + `ps --ppid` 无子进程(几十分钟后才查,子进程早退了)、 + `ws_handoff_refine` 零写入(目录是只读挂载,再忙也是零)。 + **先测分母,再报分子。** +- **但分母只解决「空间」歧义,解决不了「时间」歧义。** 2026-09-05:节点上零容器, + 可能是「从未启动」也可能是「已经跑完并自行拆除」——**这两者是同一个零**。 + checkpoint 拿另一台节点做对照,正确地排除了「exec 命名空间假象」,但**任何跨 + 节点对照都区分不了这两个,因为差别在时间里,不在读数里**。而我把「没有进程」 + 推成「body 从未启动」,实际是它在 08:25:48 就自行拆除了,checkpoint 08:27 才看。 + **解决它的是一个带活动时间戳的产物**(`deploy.log` 里的 `started_at`)—— + **要「活动」的证据,不要「状态」的证据。** +- **一次成功的重试会毁掉自己的证据。**「重试之后就好了」和「它本来就没坏」, + 事后是同一个观察。*代价:我 07:05 撞上 spur 控制平面失败,重试通了,记成 + 「暂时性」;09:45 同一个错误杀死了验收链——**第一个数据点在我手上两个半小时, + 被我当成了噪声。**「第二个实例把它从偶发变成一种条件」(m2)。* + **这条是可机械化的(tier 2):重试必须留下被它跨过的那次失败的产物。** + m1 的 NCCL 重试已经这么做了——它保留 `worker.attempt1-nccl-fail.log`, + 所以那四次故障是可数的;我的 `squeue` 重试什么都没留,所以第一次不可数。 + **但这个先例只覆盖三条通道里的一条(checkpoint):** `78909fc` 改的是 + `readme.md`——**是 brief 不是代码,所以它只到达 `kind: ai` 那条路径**; + 程序路径是空白;**而丢掉第一个实例的那次重试,既不是 ai 任务也不是程序任务, + 是一个操作者在敲命令——没有 brief 到得了那里,也没有代码路径能强制它。** + 「先例已经在包里」不等于「问题解决了」——**真正失败的那个案例,恰好在先例 + 够不到的那条通道上。**(这是「brief 无法指示程序」的推广:**它也无法指示操作者。**) +- **崩溃会挡在真实驳回前面。**「崩了」不等于「否则会通过」——10 行崩溃里有 4 行 + 修好后照样驳回。 +- **「暴露」不等于「原因」。** 数「有多少行含模板」是暴露面,数「有多少行崩在 + 模板上」才是原因。 + +## 读产物,不读退出码 + +- **`SAVE_OK` 只是退出码**;拿 `docker load` 去验才知道 tar 完整。 +- **失败命令的输出会被写进产物字段。** `engine_argv.txt` 里存着 + `Error response from daemon: ...`,而三个 validator 全部通过—— + **有内容的错误文件比空文件更难发现。** +- **`| head` / `| tail` 会吞掉 rc。** 今天吞过四次,其中一次差点让我给正确的 + 代码报 bug。用 `PIPESTATUS` 或分开取。 +- **agent 的 transcript 是一件我们谁都没用过的仪器。** m4 那条线「停滞 64 分钟」, + 被诊断成挂死、被拿去质疑停滞检测器、被我建议去查 `wchan`/`stack`——**而 agent + 早就结束了这一轮并写下了完整的拒绝理由,在 transcript 里躺了 64 分钟**:启动行 + 少了 `--var gpu=`,`HIP_VISIBLE_DEVICES` 为空,而包里三处写明「body 不许自己 + 改它」,**它认出自己就是那个 body,所以拒绝挑一张看起来空闲的卡**。 + **mtime 和进程表回答「它在动吗」;transcript 回答「它在做什么、为什么停」。** +- **`tail -1` 也会吞掉内容,而这一次更贵。** 我的十分钟巡检一整天都在跑 + `grep -aE 'phase|did NOT finish' $f | tail -1`。四条「死因不明」的运行,**真正的 + 死因就写在最后一行的上面一行**——`"the escalation reached the top … Nothing has + changed for 900 s"`——**而且它含 `phase`,我的 grep 匹配到了它,然后 `tail -1` + 把它扔了。** 日志在 `/home/yihou/` 里可读了一整天;没有任何东西不可恢复, + **我们只是从没读过最后一行之前的那一行。** 取 `tail -3`,或者分开抓终止行。 + +## 不要从名字推断 + +- **容器名前缀不等于归属。** 我从 `yihou_e2e_flow_*` 推出「m1 在调试」,报给用户 + 两次,实际是 run 自己的 agent。**判据是 FATAL 之后 5 秒就出现的容器,不是前缀。** +- **删掉一行之前,先读它上面那段注释。** 那条 `froms` 边看起来是多余的排序边, + 注释说明它保护着下游唯一可信的吞吐数字,而且删错了**不会有任何报错**。 + **把它当成候选之前也要读。** m2 花了一次双臂对比去确立「`/stop_profile` 的 + `ReadTimeout` 是症状不是故障」——**而调用点上方十二行的注释早就写着**: + *「路由的 HTTP 客户端有 30 秒读超时……这里失败不构成『停止失败』的证据—— + 下面那个字节数检查才是判据。」* 作者预见到了它,并为此建了那个检查。 + **他的对比确认了一个已文档化的设计决定,而不是发现了什么。** + +## 环境 + +- **算「还剩多久」。** `squeue` 的 `%M` 是已用,上限 8 小时。 +- **`/home/yihou/dev/git` 是一个指向 `./git.16-19` 的符号链接——只有一棵树,两个名字。** + (m2,2026-09-06,leader 更正了我更窄的那一版。)**不是「同一个文件两条路径」, + 是同一棵目录树两条路径:** + ``` + /home/yihou/dev/git -> ./git.16-19 + realpath 两条 .claude/CLAUDE.md -> 同一个 + git rev-parse --absolute-git-dir 两边 -> /home/yihou/dev/git.16-19/infera/.git + branch 两边 -> dev.yihou.aiopt.task_package.concat + stat -c %h -> 1 links ← 硬链接会是 2 + ``` + **`1 links` 是判别式**:同一个 inode 加一个链接数,意味着两条路径抵达的是 + *同一个目录项*,而不是两个目录项指向一个文件。 + **所以「另一个 worktree」不存在。** 任何人从任一路径写下的东西——`bug.record`、 + `RUNG5-CHECKLIST.md`、今天提交的每一个文件——都落进同一个仓库、同一棵工作树。 + *我最初只说「这一个文件是共享的」,那是关于一个文件的句子,而问题的主语是整棵树。* +- **`/shared_nfs` 在登录节点是只读**(`ro,relatime`),计算节点可写。 + 从登录节点 `mkdir` 会静默失败——不要把 `2>/dev/null` 加在会失败的命令上。 +- **zsh 不对未加引号的变量做词分割**,`--include=*.json` 会被 glob 掉,要加引号。 +- **`--stall-after` 是双刃**:默认 20 秒让「升级无接收方」几秒内就报出来; + 我设成 3600 把同一条诊断藏了一小时。**900 秒是折中**——够一次冷启动(实测 + 222/232 秒),又不至于把失败藏起来。 + + **第三条刃,2026-09-06 实测(m2):900 秒也是 AIPerf 的请求超时。** + 引擎 18:35:24 停止生成,而 AIPerf 每 900 秒吐一批超时并写进运行树—— + **正好落在 stall 截止之后几秒,每一轮都把探测器重置一次。** 两次爆发实测相隔 + 十五分钟、各 32 条(= 并发数),运行本可以这样再活四小时。 + > **一个探测器的阈值,不只要对着「健康工作的最长间隔」校准,还要对着 + > 「下游每一个超时的周期」校准——失败会以那个周期产生活动,而活动就是它要的。** + **事后判据便宜:运行树的写入如果是规整周期,而且等于某个已知超时,那是机器的 + 签名不是工作的签名;再看产物是不是只在增长失败记录。** + *选阈值时把下游超时列一遍,不要和其中任何一个相等——这一条属于发车路径, + 不属于 bug 记录。* + + **规则的第二半,以及一个不成立的实例(m2,2026-09-06 22:3x)。** + 完整形式是:**阈值不能等于任何下游超时,并且必须大于它要坐等的最长 + 「安静间隔」——要紧的词是「安静」,不是「长」。** 一个边干活边往运行树里写的 + 阶段,再久也喂得饱探测器:`build_workset` 在 1200 秒阈值下跑了 24 分钟,两次, + 都没接近过。**真正会踩中它的,是把活交给一个安静子进程的阶段** + (真实的 forge campaign、`reverify_shapes` 调大)。**没有测量历史的阶段, + 你不知道那个间隔,所以要按看不见的那种情形取值。** + + > **但要小心给这条规则挑错的实例。** leader 把一次运行的死亡归给自己选的 1200, + > 说「validation 安静了二十分钟,看门狗杀掉了一个成功」。**实测不是:** + > ``` + > 20:18:48.146 manifest 写好(workset 封存) + > 20:18:48.674/.810/.998 三个 verdict,最后一个 false + > 20:18:49.008 validation_failed 升级到根任务(无 agent,无接收方) + > 20:38:48 stall 触发 + > ``` + > **输出验证用了 0.85 秒,而且是拒绝。**那二十分钟是拒绝**之后**在等一个 + > 没有接收方的升级。**链子死于拒绝,不是死于阈值;5400 也救不了它。** + > 而那个被担心「要跑几十分钟」的 validator,下一轮实测 **7 秒** + > (`reverify_shapes` 默认是 1)。 + **所以这条规则至今没有真实实例,只有一个被误判成实例的反例。** + 一条没有实例的规则仍然值得遵守——**正因为如此,取值要慷慨而不是「有理由」。** + *而把一次死亡归给一个没有造成它的选择,和「给一个没做出拯救的修复记功」是同一个错, + 只是符号相反:两者都会让下一个人对那个旋钮估错权重。* + +--- + +# Task — Handoff refine: chain the five LLM e2e optimisation stages into one graph + +Build **one** task package, +`agent_sys/examples/llm_e2e_performance_optimization/e2e-flow/`, whose graph runs +stage 1 → 5 in a **single `agent-sys run`**, to the rules in the repo-root +`mission.md` (rewritten 2026-09-03). + +Three deliverables, in order: + +1. **Definitions rewritten** to mission.md's general rules + per-module list. +2. **Mock e2e green** — mock agents fed by the 25 real sealed handoffs in + `/shared_nfs/yihou/agent_sys/cheat_for_mock/`; one run walks all five stages + and every validator passes. +3. **Real e2e** — staged, one stage promoted from mock to real at a time. + +Decided with the user 2026-09-03: **new package, the five demos stay untouched**; +**all three items this round**; **team = 5 module owners + 1 checkpoint writer** +after a solo contract freeze. + +## Background + +The five stages used to be five *separate* packages, each driven to a real +cluster run on 2026-09-02. They were not a flow: **a handoff only travels inside +one run's graph**, so five packages are five runs and nothing chains. `e2e-flow/` +is the single package that joins them. + +**This is a refine of definitions, not a rewrite of bodies.** The ~20k lines of +`.py`/`.sh` assets carried over are the only thing here that has ever produced a +number; they move and adapt, they do not get re-derived. + +### 进度(2026-09-05 11:5x,读表得来,不是外推) + +| 阶段 | 状态 | +|---|---| +| ① 造 handoff + 重构 validator | 完成 | +| ② 单独并行跑每个 mock | 完成 | +| ③ 串通 mock | **完成** — 3 m 46 s 走完五阶段,15 个 handoff,`run complete`(validator 关);开回 20 个后阶段 1、2 通过 | +| ④ 单独跑每个真实 | m1 ✅ m2 ✅ m3 ✅ / **m4 首次跑起 campaign 未完成** / **m5 从未跑过** | +| ⑤ 串通整个真实 | 最远 = mock 1–2 + 真实 3 完成 + 真实 4 进行中 | + +**这张表会过期。改它之前先读运行日志,不要照抄。** + +## Context — this environment (rows carry their own measurement date) + +| | | +|---|---| +| where I am | login node, **no GPU**, no direct docker daemon | +| GPU holds | **Do not read a hold or a node IP from this file — run `squeue -u $USER` and resolve the IP from the node itself.** Holds cap at 8 h; every row here expires faster than the file is re-read. *2026-09-05: the two jobs this row used to name (`106250`/`crsuse2-m2m-061`, `106253`/`crsuse2-m2m-031`) were both long gone, and m1 took `10.245.159.129` from here and nearly pointed a real deploy at another tenant's host. "measured 2026-09-03" was honest labelling and did not prevent it.* | +| reaching them | `spur exec bash -c '...'` — exec namespace; docker talks to the **host** daemon, but the **filesystem identity is you, not root** (measured 2026-09-04: `id -u` → `50112975`, writes land `-rw-r--r-- yihou ubuntu`). Matters because `/home` is `sec=sys` NFS, where a root-squashed write would map to `nobody` and leave a tree nobody can clean up. | +| shared FS | `/shared_nfs` 360 T, **46 T free**, shared by every spur node — this is how "remote" works | +| scratch | `/shared_nfs/yihou/agent_sys/ws_handoff_refine/` — **writable from a compute node only.** `/shared_nfs` is `ro,relatime` on the login node, so this path is not scratch for the leader; see the debugging section. | +| mock inputs | `/shared_nfs/yihou/agent_sys/cheat_for_mock/` — 25 real sealed handoffs, one folder per kind, **sealed 2026-09-02 and therefore older than this round's contract**: zero `environment.yaml` in 442 files, and the `operator_workset` has no `workset.yaml`. Do not delete it and do not hand-write the missing parts — graft today's real artefacts (see rule 10). Patched copy: `/home/yihou/cheat_for_mock.20260905T115337/`, originals kept beside each graft as `content.pre-0905`. | +| fast loop | `python3 -m agent_sys.cli.main show --package --var …` loads and type-checks every yaml in **< 1 s** | +| **4-minute loop** | **the whole five-stage graph runs on the login node with no GPU in under 4 minutes** — `--package e2e-flow-noval --var mock_stages=all` plus `MOCK_IMAGE_ID=sha256:…` as an **environment variable** (without it `mock_adapt.sh` cannot read the digest, exits 3, and the task is recorded `succeeded` anyway). Measured 2026-09-05: 3 m 46 s, 15 sealed handoffs, `run complete`. Every question that used to cost a GPU hold and forty minutes is asked here now. | + +## Key references + +- **`mission.md`** (repo root) — the authority. Every requirement below traces to + a numbered item there. +- `/shared_nfs/yihou/agent_sys/cheat_for_mock/README.md` — **four things that + will mislead you**, including a `kernel_table` that is a 34-row synthetic seed + and an `integration_report` carrying a *refused* verdict. +- `/shared_nfs/yihou/agent_sys/debugging/integration/DELIVERY-NOTE-FROM-LEADER.md` + — why that refusal was the validator working, and why the 5 % / 10 % bars must + **not** be widened. +- `agent_sys/spec_loader/validate.py:34-56` — the `jsonschema` idiom to copy. +- `e2e-flow/RUN-PLAN.md` — **the canonical launch block. Open it FIRST, then diff + your line against it.** Everyone who did it in the reverse order paid: it is the + only place `--timeout 21600` appears, and `--var transport=spur` was missing + from it until 2026-09-05 and cost m3 a run two hours in. +- `e2e-flow/WHAT-GREEN-ESTABLISHES.md` — which validators have ever refused + anything, and what a PASS from each one is worth. + +## Core principles + +1. **Read the artefact, not the exit code.** Every acceptance claim names a file + to open and a condition that fails. +2. **`items_schema` is not a schema layer.** Measured: for a file/tree item + `handoff/content.py:184-197` validates the *filename string*, never the + contents, and the schema is never exported to a body. Mission rule G2 needs + real schemas under `assets/schemas/`, loaded by producer **and** validator. +3. **Every identifier bound on a shared host is a parameter.** Container names, + ports, workdir, served model name. `: "${VAR:=…}"`, never `export VAR=`. +4. **Deletion — see standing rule 3 at the top of this file; that is the rule.** + It is wider than the version that stood here until 2026-09-05: **every path on + every host**, not just `/shared_nfs`, and `/tmp` is an allowed substring + alongside `yihou`. `CONTRACT.md` §5.2 agrees. + *This entry used to carry the pre-widening wording and therefore **permitted + deletions the current rule forbids** — under `/home`, `/mnt/m2m_nobackup`, and + inside container mounts. Found by readme-cn auditing this file 2026-09-05.* + **Container removal is standing rule 1, not this entry**: `docker stop -t 10`, + never `rm -f`, and "you did not create it" is **not** a reason to leave a + foreign GPU user running — rule 1 retired that caution. + Never `agent-sys run --clean` on a shared root — it removes *every* run. +5. **Research → gather → analyse → plan → work.** The repo receives only + `e2e-flow/` and `todo.md`. + **Scratch is `/home/yihou/`, not `/shared_nfs`.** `/shared_nfs` is + `ro,relatime` on the login node (measured), so anything the leader writes goes + under `/home/yihou/` — which also satisfies the deletion rule's `yihou` + substring. +6. Bugs in `agent_sys` are recorded under + `agent_sys/examples/llm_e2e_performance_optimization/temp/bugs/` first, then + worked around; fixed only when the evidence is unambiguous. + **`temp/` is gitignored while all its records are tracked** — a narrow + `git add -f ` is house style, not an override. +7. Work in English; report to the user in Chinese. + +## Other notable details + +### Two mission requirements that read as traps + +- **Rule 7, "all tasks share one docker container".** Module 5 needs **two** by + construction — a container holds one state for its life, which is the whole + reason the two-arm design exists. G5.1's *"如果不行,再考虑…启动不同的 docker + container"* grants the exception: modules 1–4 share one, module 5 brings up + its own two arms from the same image and the same `environment` record. +- **M5.1.3, Python runtime hijack.** M5.3 says *"首先记入 todo … 但现在就这样吧"* + — record the disagreement in `todo.md`, **keep `overlay_files`**. + +### DCO sign-off is required on every commit + +CI blocks any PR containing a commit without a `Signed-off-by:` trailer. ```bash +git commit -s -m "..." git config user.name && git config user.email ``` -If those are empty or wrong, set them (add `--global` outside a container): +Sign off **as yourself** — never a colleague's line, never a bot identity. -```bash -git config user.name "Your Name" -git config user.email "you@example.com" -``` +### Branch / PR -### Fixing commits that are already missing it +Branch `dev.yihou.aiopt.task_package.concat`. Activity is limited to +`agent_sys/examples/llm_e2e_performance_optimization/` plus the root-level +`*.md` notes this effort writes. -Sign off a range without disturbing commits that already carry the trailer — -rebasing from a point that includes signed commits appends duplicates: +--- -```bash -git rebase --signoff -git push --force-with-lease origin -``` +# Task — Reproduce, debug and accept the LLM e2e opt chain on THIS cluster -Pick `` as the newest commit that already has the -trailer. Check what you are about to touch first: +Authority: repo-root **`mission.md`** + **`mission.verify.e2e.md`**. Everything +below traces to one of them. The previous round's file is +`.claude/CLAUDE.handoff-refine.20260906-0627.md.bak` — **it is about a different +cluster**; read it for mechanism, never for values (nodes, jobids, `/shared_nfs` +paths, `spur`, 8-hour holds — none of that is true here). -```bash -git log --format='%h %s | %(trailers:key=Signed-off-by,valueonly)' origin/main..HEAD -``` +## The job + +`agent_sys/examples/llm_e2e_performance_optimization/e2e-flow/` — one package +whose graph runs module 1 → 5 in **one `agent-sys run`**. No `agent_sys` core +changes. + +1. **Reproduce** the first cluster's board here (modules 1, 2, 5 real). +2. **Complete all five stages in one run, reaching `packup`.** +3. Run module 4 for real if compute allows. **It is not this hold's goal.** +4. Debug what breaks and record it. + +### The user's two decisions, 2026-09-06 06:2x + +- **Two phases on the model.** Phase A: `Qwen3-32B` + the stock image already on + this host — get the chain green with the cheapest possible bring-up. Phase B: + swap `image`/`model_path` to GLM-5.3-Flash and re-run once for acceptance. +- **Priority is 1→2→3→5 + `packup`.** Module 4 rides on a replayed or + self-declared-degraded artefact this hold. A degraded artefact **must say so in + itself**; a `speedup: 1.0` from an empty-change baseline is a correct value, + not a guess. + +--- + +## Context — this host (every row measured 2026-09-06 06:2x; re-measure, do not inherit) + +| | | +|---|---| +| where I am | **on the compute node itself**, `smci355-ccs-aus-n04-25`. Not a login node. `docker` talks to the local daemon. | +| GPUs | 8 × MI355X (gfx950), **all idle, VRAM 0 %** at 06:25 | +| the hold | slurm job **29184**, `TimeLimit=16:00:00`, **`EndTime=2026-09-06T14:00:01`**. Partition timelimit is `infinite` but this job's is not. **Reckon time remaining, and re-read `scontrol show job 29184` — do not trust this row.** | +| transport | **`--var transport=local`, and it must be asked for.** `assets/lib/remote.sh` has a `local` branch that the probe **never selects** ("no transport binary present" ≠ "I am on the node"). `spur` is absent, `srun` is present, so the probe would pick `srun` and step into an allocation we are already inside. | +| scratch | **`/data/yihou/e2e_verify_20260906/`** — local `/dev/md0`, 41 T free, writable. All temporary activity here. | +| run root | **`/data/yihou/agent_sys_runroot`** | +| `work_root` | must be **local disk**: `/data/yihou/e2e_flow`. `/home` is autofs NFS; `/apps` is NFS and 99 % full. The package warns a root-squashed network home makes the engine fail to write logs *silently*. | +| model (phase A) | `/apps/data/models/Qwen3-32B` (~65 GB, plain directory) | +| model (phase B) | `/apps/data/models/GLM-5.3-Flash` (306 GB) + an image built from `/apps/yihou/packups/glm53flash.mix.packup_20260830/Dockerfile.sglang.glm53` | +| image (phase A) | `lmsysorg/sglang-rocm:v0.5.18-rocm724-mi35x-20260903` — already loaded | +| etcd image | `quay.io/coreos/etcd:v3.5.14` — already loaded | +| network | outbound HTTPS to quay.io and github.com works (200) | +| **no corpus** | `/shared_nfs` exists and is **empty**. The first cluster's 25 sealed handoffs are NOT here. **So nothing can be mocked until we have produced our own.** The ladder is bottom-up: run a stage real → seal → `assets/lib/replay_root.py` materialises a `mock_root` from it (`SKIP-AHEAD.md` is the manual). | +| foreign tenants | containers `rc_26_7_902` and `xiaoming-dev` are running and **hold no GPU**. Leave them. Re-check before every bring-up. | +| fast loop | `PYTHONPATH=$PWD/agent_sys python3 -m cli.main show --package … --var …` type-checks every yaml in < 1 s. **`PYTHONPATH` is mandatory** — a bare `agent-sys` resolves to the `infera.aiopt.all` worktree, not this one. | + +--- + +## Key references, in the order to read them + +1. **`e2e-flow/RUN-PLAN.md` § `## 2. The command — CANONICAL LAUNCH BLOCK`** + (~line 2299). **This file holds SEVEN launch blocks and they disagree.** Diff + against that one and no other; its heading is unique on purpose. +2. **`e2e-flow/WHAT-GREEN-ESTABLISHES.md`** — which validators have ever refused + anything, and what a pass from each is worth. +3. `e2e-flow/CONTRACT.md` — the frozen fifteen-kind cross-module contract. +4. `e2e-flow/SKIP-AHEAD.md` — the replay mechanism, **and its first page says it + is a debugging accelerator and never an acceptance path.** +5. `/apps/yihou/packups/glm53flash.mix.packup_20260830/` — how GLM-5.3-Flash was + brought up on an 8×MI355X box, for phase B. + +--- + +## Core principles -Cherry-picks do **not** inherit the trailer — `git cherry-pick -s`, or sign off -afterwards. This is the easiest way to reintroduce the problem on a second branch -after fixing it on the first. +1. **Read the artefact, not the exit code.** Every acceptance claim names a file + to open and a condition that fails. +2. **Before attributing ANY refusal**, run + `bash assets/lib/refusal_saw_something.sh ` — **`bash`, not `sh`**. + A validation zone is sometimes handed **zero files** (~1 in 11–13 zones); + a refusal from a zero-file zone says nothing about the artefact, and a PASS + carries no reason at all, so the file count is the only retrospective check. +3. **Record your launch line beside the run.** The staged package keeps + `${var:-default}` unrendered, so which `--var` a run used **cannot be read + back from the artefact**. Four separate incidents trace to this. +4. **Audit the whole variable table against your `mock_stages` every launch** — + not the rows you remember. `expect_ranks`, `adhoc_cases`, `bench_rounds` each + have one value when a stage is mocked and another when it is real; three + launches were lost fixing them one at a time. +5. **`E2E_KIT_PORT_BASE` moves the whole port band.** `port_router` moves one + port and leaves the band behind. A collision makes the body `exit 1` with no + escalation recipient, so the task sits at `running` and the log reads healthy + for the full stall timer. +6. **The `run_with_long_stall.py` wrapper is not optional.** `stall_after` is not + on the CLI; a bare launch gets the 20-second default and a real rung cannot + survive it. The wrapper prints `stall_after 20s -> 900s` when it takes — **if + that line is absent, kill and re-issue.** +7. **Module 5 brings its arms up through a node-wide GPU reset** that kills every + GPU process whose command name matches the engine's. It protects no + co-tenant. Its arms are pinned to the first TP devices and two of its + coordination ports are literals, **so a run with a real module 5 owns the + host.** Check the cards and the container list before every bring-up. +8. **Killing an orchestrator does not kill its agents**, and an agent will + rebuild a container about a minute after you stop it. **Order: agents first, + then containers, then verify.** Before any irreversible action against a run, + open the transcript or the event's `attributes.detail` — not the phase line. +9. **Pre-register.** Write down what each outcome would mean, and what the + current state is, *before* the result arrives. +10. **Never let a computed value reach a destructive command unchecked** + (`[ -n "$X" ] || exit 1`). **Deletion: nothing whose path lacks `yihou` or + `/tmp`.** Stop containers with `docker stop -t 10`; never force-remove one + you did not create. Never `--clean` a shared run root. +11. **Do not infer ownership from a name.** Ownership is a label, an + auto-remove flag and a process list. +12. **A tool that returns a well-formed answer on bad input is the dangerous + kind.** Before trusting a new instrument, run it on a case whose answer you + already know. +13. **Do not request machines.** Query and use what is held; if there is not + enough, say so. -## Related trailers +## Working rules -`Signed-off-by` is the DCO assertion and is mandatory. `Co-Authored-By` is -separate, is not a substitute, and some upstreams reject assistant co-author -trailers outright — when contributing to a third-party repository (e.g. ROCm/aiter), -do not add them. +- **Work in English. Report to the user in Chinese.** +- **Every commit is `git commit -s`.** CI rejects a commit without the trailer. + Branch `dev.yihou.aiopt.task_package.concat`. The repository receives only + `agent_sys/examples/llm_e2e_performance_optimization/` and the root `*.md` + notes. +- **Agent team.** Leader polls every 10 min; a problem seen once is recorded, and + intervened on at the second sighting **unless it is burning GPU or hold time, + in which case intervene at the first.** An owner with nothing assigned is the + leader's failure. +- **A checkpoint writer appends to `work.checkpoint.summary.md` every 30 min**: + estimated completion %, elapsed and projected time, reliability; current + state; code problems fixed/unfixed; non-code problems; open questions; new + commits one line each. +- Framework bugs → `agent_sys/examples/llm_e2e_performance_optimization/bug.record.2026-09-06.md`; + validator failures → `validator.failures.2026-09-06.md`, same directory. +- **Do not change the host** (root filesystem, system state) without asking. diff --git a/.gitignore b/.gitignore index 3d850f6d0..1804e41aa 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,12 @@ mission.md .spur_job_*.sh .spur_ns_*.sh spur-*.out + +# serena writes `.serena/` into whatever directory it is given as +# `--project`; ignored rather than remembered, since `launch.sh` refuses to +# start a run while the package tree is dirty. +.serena/ +# Generated debug copies of e2e-flow with validators disabled +# (assets/lib/make_debug_package.py). Regenerated, never edited by hand. +e2e-flow-noval*/ +e2e-flow-keep*/ diff --git a/agent_sys/agent/backend.py b/agent_sys/agent/backend.py index 9765b9cd3..e6c5a101a 100644 --- a/agent_sys/agent/backend.py +++ b/agent_sys/agent/backend.py @@ -182,38 +182,25 @@ class Assignment(BaseModel): #: which is what every backend but `claude_sdk` does today. tools: tuple[Any, ...] = () + #: External MCP servers this agent's components declared, keyed by the + #: name the model addresses them under (`env_mgr.Prepared.mcp_servers`, + #: straight through). Typed loosely: the values are the SDK's own server + #: vocabulary, which `agent` does not own or check. A backend that cannot + #: express external servers ignores this entirely. + mcp_servers: dict[str, Any] = Field(default_factory=dict) + #: `env_mgr.Confinement`, carried for an executor that wants to report what #: it will run under. Typed loosely because `agent` may not import `env_mgr`. - #: - #: **A prediction, not a report**, since `interfaces.md` split step 7: - #: nothing has been applied when this arrives, and `spawn` realises it in - #: the child. `mechanism`, `network`, `pid` and `abi` are all knowable at - #: prepare time and are accurate — and the run does get them, because - #: `spawn` cannot silently skip the confinement. So it is safe to record; - #: what would be wrong is reading it as *already in force*. - #: - #: **The wrapper is deliberately not here.** It used to be, and that was the - #: defect `closure`'s review found: a field an executor *may* read is a - #: field an executor may silently *not* read, and the one that does not is - #: the AI backend — the executor whose confinement matters most. It arrives - #: through `accept_confinement` instead, which an executor that cannot - #: honour it refuses. + #: **A prediction, not a report**: nothing is applied when this arrives, and + #: `spawn` realises it in the child. The wrapper is deliberately absent and + #: arrives through `accept_confinement`, which an executor may refuse. confinement: Any = None #: **What this task must deliver, as text for the agent** — each declared #: output, its kind, and where it goes. Empty for a task with no outputs and #: for a `kind: program` body, which is told by its environment instead. - #: - #: **Facts, never guidance**, which is the line `main` drew when ruling this - #: onto the runner: the path is per-attempt and computed at dispatch, so no - #: readme can name it and no package could. What the work *is* — the - #: contract, what counts as done — stays in the readme, which is the - #: package's to write and this field must never grow into. - #: - #: **Separate from `readme` on purpose.** `readme` means *the package's - #: brief, as written*, and a program executor receives it too; machine text - #: merged into it would make one field two things and leave no way to tell - #: which half a reader is looking at. + #: **Facts, never guidance**: the path is per-attempt and computed at + #: dispatch, so no readme can name it. What the work *is* stays in `readme`. outputs_brief: str = "" #: `env_mgr.Prepared.agent_cli` — the CLI this environment was provisioned diff --git a/agent_sys/agent/backends/claude_sdk.py b/agent_sys/agent/backends/claude_sdk.py index 3c4e8663e..6893e1f6a 100644 --- a/agent_sys/agent/backends/claude_sdk.py +++ b/agent_sys/agent/backends/claude_sdk.py @@ -357,20 +357,35 @@ def _options(self) -> dict[str, Any]: options.setdefault("permission_mode", "bypassPermissions") if self.assignment.environment: options.setdefault("env", dict(self.assignment.environment)) + if self.assignment.mcp_servers: + # Per-agent components' external servers, under the same collision + # policy as the tool server: a name may not be taken twice, since + # `mcp____` is what the model calls. A collision is + # refused rather than merged, so neither side silently loses tools. + servers = dict(options.get("mcp_servers") or {}) + clash = sorted(set(servers) & set(self.assignment.mcp_servers)) + if clash: + raise BackendUnsupported( + self.key, + "mcp_servers", + f"this config and this agent's components both declare MCP " + f"server(s) {clash}. The model addresses these as " + f"mcp____, so two servers cannot share a name — " + f"rename one side rather than letting the other's tools " + f"disappear.", + ) + servers.update(self.assignment.mcp_servers) + options["mcp_servers"] = servers if self.assignment.tools: # **Spec §5.5's remote surface, and the only place that knows the # SDK.** `env_mgr` may not import the SDK and `agent/backend.py` is # backend-agnostic, so the `ToolDef` -> `SdkMcpTool` adapter belongs # here beside every other option this file assembles. server, names = _tool_server(self.assignment.tools) - # **A collision is named, not resolved.** This was - # `options.setdefault("mcp_servers", {})[_TOOL_SERVER] = server`, - # which silently replaced a caller's own server of the same name — - # the only line in this method that overwrites rather than - # `setdefault`s, and the one whose key is a compatibility surface - # (`mcp__env_mgr__…` is what the model calls). An operator who lost - # their tools that way would get no message and no failure, just - # different tools than the ones they configured. + # **A collision is named, not resolved.** The key is a + # compatibility surface — `mcp__env_mgr__…` is what the model calls + # — so replacing a caller's own server of the same name would hand + # them different tools with no message and no failure. servers = dict(options.get("mcp_servers") or {}) if _TOOL_SERVER in servers: raise BackendUnsupported( @@ -466,9 +481,45 @@ def _deliver(self, message: str) -> None: self._await(self._client.query(message)) def _terminate(self) -> None: - if self._connected: - self._await(self._client.disconnect()) + """Disconnect the SDK and deterministically dispose of its private loop. + + A successful submission deliberately leaves the client connected: the + completeness gate may request another submission on the same session. + `stop()` is the owner boundary where that reuse has ended. Merely + dropping the loop here leaves the SDK's `Query._read_messages` task and + subprocess transport alive; Python then destroys them after the loop is + closed and emits `Task was destroyed but it is pending`. + + Idempotence matters because scheduler stop and process shutdown can both + reach the same executor. + """ + with self._loop_lock: + if self._loop.is_closed(): + self._connected = False + return + try: + self._loop.run_until_complete(self._shutdown()) + finally: + self._loop.close() + + async def _shutdown(self) -> None: + """Close the SDK first, then cancel anything it left on this loop.""" + try: + if self._connected: + await self._client.disconnect() + finally: self._connected = False + current = asyncio.current_task() + pending = [ + task + for task in asyncio.all_tasks(self._loop) + if task is not current and not task.done() + ] + for task in pending: + task.cancel() + if pending: + await asyncio.gather(*pending, return_exceptions=True) + await self._loop.shutdown_asyncgens() # ---- level 2 ---------------------------------------------------------- # diff --git a/agent_sys/agent/docs/design.md b/agent_sys/agent/docs/design.md index b3892334a..ff452c6b1 100644 --- a/agent_sys/agent/docs/design.md +++ b/agent_sys/agent/docs/design.md @@ -1297,7 +1297,7 @@ test the chain against nothing that can actually fail to be available. | **O1** | **Criterion 13 is not testable as written.** "Losslessly for what both support" requires knowing the intersection of two harnesses' feature sets, and no converter computes it — everyone hand-maintains a table. Both reference implementations' tables fail invisibly: **pandoc** classifies a dropped block as `INFO`, so `--fail-if-warnings` exits 0 with the content gone, and attribute-level loss (a link title) is logged at *no* level; **kompose**'s 25-entry unsupported-key table has **no production caller** — its only caller is its own unit test, and the exported sibling is invoked with an empty map, so eight declared-unsupported keys converted clean with exit 0. The only executable formulation found anywhere is `rulesync`'s per-(target, feature) fixtures asserting the *canonical* value. **The criterion needs to name the artefact that defines "what both support" and the test that keeps it honest** — and to separate *unsupported* (the target cannot express it) from *unknown* (the converter did not handle it), which GitHub Actions Importer does and which criterion 13 conflates | | **O2** | **Which `claude` CLI the backend runs is undecided, and it is `env_mgr`'s call.** The SDK prefers its bundled 328 MB executable; `env_mgr` installs plugins into whatever is on `PATH`. Unless `cli_path` is set from the prepared environment, an agent does not see the plugins its own recipe installed. §8.7 | | **O3** | **The backend's transcript lands outside the confinement zone.** `~/.claude/projects//*.jsonl`, containing prompts and reasoning, written by default. Criterion 16 is about the system's record and stays true, but "an agent reaches only its own zone" does not. Three levers exist (`CLAUDE_CONFIG_DIR`, `CLAUDE_CODE_SKIP_PROMPT_HISTORY`, a `SessionStore`); choosing one is `env_mgr`'s, and its spec does not mention the directory | -| **O4** | **§4.5's "Claude Code's format" is ambiguous.** The declarative `.claude/settings.json` surface and the SDK's `ClaudeAgentOptions(hooks={...})` callbacks are different execution models. Every surveyed converter targets the first; **nobody converts programmatic callbacks at all.** §4.5 must name which surface is canonical, and if it is the callbacks, criterion 13 has no prior art of any kind behind it | +| **O4** | **ANSWERED — the declarative `settings.json` surface is canonical.** The question was whether *"Claude Code's format"* meant the `.claude/settings.json` tree or the SDK's `ClaudeAgentOptions(hooks={...})` callbacks; every surveyed converter targets the first and **nobody converts programmatic callbacks at all.** Per-agent components forced the ruling, because they had to be *stored* somewhere: L2 and L3 are `.claude/` trees, `env_mgr/agent_assets.py` writes `/config/settings.json` and points `CLAUDE_CONFIG_DIR` at it, and `spec.md` §4.5 now says so. The warned-of consequence is therefore not incurred — criterion 13 rests on the surface the prior art covers. The callback form stays legal in a backend `config` and is passed through; it is simply not the stored form. **What is still open is narrower and belongs to O1**: no converter exists, so "canonical" is currently a claim about one harness rather than a demonstrated N-to-1 | | **O5** | **Two objects hold "the agent spec table".** `AgentSpecRegistry` here, and `AgentMgr.register(spec, **config)` in `task_graph`, which copies its dict onto every minted `Agent.config`. This design assumes the loader feeds the second from the first and that nothing else writes either — but the direction is not stated in any spec, and `engineer_principle.md` §1 forbids two writers for one fact | | **O6** | **How each phase becomes separately attributable** — narrowed twice, and the question is now smaller than rev. 3 stated it. `validator` design §8.2 rev. 2 owns the **requirement**: a phase must carry an `agent_id`, because criterion 10 there is untestable otherwise, and the SDK's `agent_id` is *"absent on the main thread"*. This module owns the **mechanism**, and one candidate is ruled out: not one client with several `session_id`s, because `interrupt()` takes no `session_id` and acts on the whole connection (§8.4). `fork_session`, `resume`, a subagent per phase, and a second client remain, and none was tested. The stage-three consistency pass found this document and `validator`'s giving different answers to what turned out to be two different questions; splitting them is what made the residue this small | | **O7** | **Mid-run backend failure.** §3.3's "pins the whole run" implies no fallback after the chosen backend dies, and every surveyed project except LiteLLM agrees. LiteLLM's cost is on record — a depth bound, an attempted-targets set against looping graphs, a pin predicate, cooldown feedback and per-failure-class chains, threaded through a loosely-typed `kwargs` at four call sites. Worth knowing before anyone proposes it, and worth stating in the spec either way | diff --git a/agent_sys/agent/docs/spec.md b/agent_sys/agent/docs/spec.md index 7e9cecc7e..f1333375d 100644 --- a/agent_sys/agent/docs/spec.md +++ b/agent_sys/agent/docs/spec.md @@ -104,6 +104,16 @@ spec** — the two are declared together in a closure. | `env` | Environment requirements, resolved by `env_mgr` | | `knowledge` | §3.4 | | `rules` / `hooks` / `skills` | Configuration, stored in canonical form. §4.5 | +| `assets` | **Filled by `spec_loader`, not written.** This agent's own directory under the package's `assets/`, found by the same three folder spellings a body lookup uses — `X`, `X.agent`, `agent.X`. Two matching directories is `SpecInconsistent`; an explicit binding is legal and warns. §4.5a | +| `recipes` | The **agent layer** of three recipe layers; `env_mgr` recipe YAMLs as `agent_sys:` or `package:` — a reference names its root. §4.5a | + +Nine keys became eleven, and both additions are one thing: **an agent may now +carry components, not only files.** §4.5a is why that needed new keys instead of +a longer `skills` list. + +**There was a third, `agent_plugins:`, and it is deleted** — +`docs/spec.provisioning.md` §4: what this repository ships under +`env_mgr/addons/` is installed by a recipe, and no declaration key reaches it. ### 3.2 Permissions are not here @@ -377,6 +387,62 @@ Picking one canonical format matters more than which one is picked: with N harnesses, storing each in its own format needs N² converters, and storing one canonical form needs N. +**Which Claude Code surface is canonical: the declarative one.** Design O4 asked, +because *"Claude Code's format"* named two different execution models — the +`.claude/settings.json` tree and the SDK's `ClaudeAgentOptions(hooks={...})` +callbacks — and every surveyed converter targets the first while nobody converts +programmatic callbacks at all. The answer is the first, and it is now what the +code does: `env_mgr` writes `/config/settings.json` and points +`CLAUDE_CONFIG_DIR` at it. The consequence O4 warned about is therefore not +incurred — criterion 13 rests on the surface the prior art actually covers. + +The callback form is not forbidden; a backend config may still carry `hooks`, and +`claude_sdk` passes it through. What it is not is the **stored** form, so nothing +in a package or a component is written that way. + +### 4.5a A component is a tree, and that needed three keys + +§4.5's three lists are lists of *files*. A Claude Code component is a directory: +a skill is a directory, a plugin marketplace is a directory of directories, and +an MCP server is a process to register rather than a file to place. Naming every +file would make a package author restate a layout the harness already fixes. + +**Two routes, and only one copies a tree.** `docs/spec.provisioning.md` is +normative here and supersedes both the L1/L2/L3 numbering and the three-origins +table that replaced it. + +| what | declared how | +|---|---| +| anything upstream ships, and anything `agent_sys` ships under `env_mgr/addons/` | `recipes: [...]`, or the package/default recipe layer; `tags: [internal]` marks an item as ours | +| what one task package carries for one agent | **undeclared** — `/.claude/`, copied | + +**The copied tree is in Claude Code's canonical layout**: +`settings.json`, `skills//`, `plugins/` (a local marketplace), +`.mcp.json`, and `tools/*.mcp.py`. It is the harness's own layout rather than +ours, so a file is placed and not converted. (A `tools/*.tooldef.py` was a fourth +member until 2026-09-04, when the in-process route it used was deleted — +`docs/spec.provisioning.md` §6.) + +**A package's own material is undeclared on purpose.** A declaration would be a +second statement of what the directory already says, and the two would drift the +first time somebody moved it without editing the YAML. + +`env_mgr/agent_assets.py` installs both; `env_mgr/docs/design.md` §11.5a is +the mechanism, including the measured ordering constraint that decides when +`settings.json` is written, the marketplace copy probe F forced, and why a +recipe runs the shipped machinery as a subprocess. What reaches this package +from a component is `Assignment.mcp_servers`. `Assignment.tools` also exists, +but nothing a component ships arrives through it — it carries `env_mgr`'s own +remote surface and nothing else (§5.5). + +**A component names a binary through `${VAR}`, never through `PATH`.** An +`.mcp.json` entry is expanded against the zone environment before it becomes an +`mcp_servers` entry, and an unresolved name is an error. That is not a +convenience: `PATH` is derived from the granted policy at prepare step 2, and a +directory a recipe installs into does not exist until step 6b — so +`"${UV_TOOL_BIN_DIR}/serena"` is the only spelling that works, and it is the one +measured working. + --- ## 5. `claude-agent-sdk` as the first backend diff --git a/agent_sys/agent/runner.py b/agent_sys/agent/runner.py index 2acd8d58a..afebfb05f 100644 --- a/agent_sys/agent/runner.py +++ b/agent_sys/agent/runner.py @@ -182,6 +182,36 @@ def stop(self, task_id: TaskId, on_stopped: Callable[[TaskId], None]) -> None: self._attempts.pop(task_id, None) on_stopped(task_id) + def shutdown(self) -> None: + """Release every attempt when the process-level run is over. + + Attempts outlive their worker thread on purpose: a monitor can wake a + parked task or submit to the same executor again while the run exists. + Once all monitors have stopped and the CLI has produced its report, + there is no remaining owner for those executors. Keeping them in + `_attempts` leaked each Claude SDK reader task and subprocess transport + until interpreter teardown. + + The map is emptied before any potentially blocking backend shutdown, so + no concurrent observer can acquire an executor whose disposal started. + Every attempt is given a chance to stop even when one backend raises; + the first error is re-raised after all worker threads have been joined. + """ + with self._lock: + attempts = tuple(self._attempts.values()) + self._attempts.clear() + + first_error: Exception | None = None + for attempt in attempts: + try: + attempt.halt() + except Exception as exc: # cleanup all owners before reporting one failure + first_error = first_error or exc + for attempt in attempts: + attempt.join(HANDOVER_GRACE) + if first_error is not None: + raise first_error + # ---- the monitor's two entrances, not on the protocol ----------------- # # `Runner.resume` is gone. `carry_on` subsumed it, `monitor`'s Protocol @@ -560,9 +590,12 @@ def halt(self) -> None: """`Runner.stop`: end the executor and the thread.""" with self._own: self._halted = True - if self.executor is not None: - self.executor.stop() - self._wake.set() + try: + if self.executor is not None: + self.executor.stop() + finally: + # A backend cleanup error must not leave the attempt parked forever. + self._wake.set() def join(self, timeout: float | None = None) -> None: """For a caller that wants the thread settled. Tests, and `demo`.""" @@ -761,6 +794,10 @@ def _deploy(self, spec: Any) -> Executor: # `Prepared` from before this field existed is still a valid one, # which is the same allowance every other optional field here gets. tools=tuple(getattr(prepared, "tools", ()) or ()), + # The per-agent components' external servers, same allowance and + # for the same reason: a `Prepared` built before this field existed + # is still a valid one. + mcp_servers=dict(getattr(prepared, "mcp_servers", None) or {}), confinement=getattr(prepared, "confinement", None), agent_cli=prepared.agent_cli, # **Read, not inferred**, and that is the field's whole reason. diff --git a/agent_sys/agent/spec.py b/agent_sys/agent/spec.py index 4f353c3b8..f84abbf07 100644 --- a/agent_sys/agent/spec.py +++ b/agent_sys/agent/spec.py @@ -106,15 +106,21 @@ class AgentSpec(_Model): hooks: list[str] = Field(default_factory=list) skills: list[str] = Field(default_factory=list) + #: This agent's own directory under the package's `assets/`, package-relative, + #: or `""` when it has none. Filled by `spec_loader`, not written by hand, + #: from the same folder convention that scopes a task's body lookup. + assets: str = "" + + #: `env_mgr` recipe YAMLs run before the session, each written + #: ``:`` (``agent_sys:`` or ``package:``); a + #: reference always names its root. Also filled by convention from the + #: agent's own recipe under `assets/`; declaring it by hand warns and wins. + recipes: list[str] = Field(default_factory=list) + @field_validator("backends", mode="before") @classmethod def _normalise(cls, value: Any) -> Any: - """A mapping form is normalised to a list, preserving declaration order. - - Spec §3.1 permits "a list or dict"; design D2 stores a list, because the - order is load-bearing (criterion 3) and a dict that carries an ordering - is a dict whose ordering nobody can see at the call site. - """ + """A mapping form is normalised to a list, preserving declaration order.""" if isinstance(value, Mapping): return [{"key": key, **dict(decl)} for key, decl in value.items()] return value diff --git a/agent_sys/cli/environment.py b/agent_sys/cli/environment.py index c70fff411..04748b7b5 100644 --- a/agent_sys/cli/environment.py +++ b/agent_sys/cli/environment.py @@ -34,9 +34,11 @@ from pathlib import Path from typing import Any, NamedTuple +from env_mgr import harness from env_mgr.fs.domain import DomainRegistry from env_mgr.isolation.policy import Granted, Mode, interpreter_grants from env_mgr.isolation.probe import Availability, probe, select +from env_mgr.prefix import CLAUDE_CONFIG_ENV_VAR, Prefix from env_mgr.protocols import Context, DomainKind, NoConfinement, Tier __all__ = [ @@ -45,7 +47,9 @@ "Layout", "LiveHandoffs", "build_context", + "WORKROOT_ENV_VAR", "confinement", + "default_root", "demo_grants", "latest_run", "layout_for", @@ -214,14 +218,48 @@ def __len__(self) -> int: return 0 if self._mgr is None else len(self._mgr.all_ids()) +#: The run root, named outright. `--demo-root` still wins; this is what a +#: caller who cannot pass an argument sets. +#: +#: **It names the root itself, not a base to append to.** `XDG_STATE_HOME` is a +#: base — the spec says what may go under it and every program appends its own +#: name — so `agent-sys-demo` is this program's share of a directory it does not +#: own. That is the wrong shape for the thing this variable exists to control: +#: the run root has to be **one absolute path that resolves identically here and +#: on the compute node**, because `remote.sh:require_visible_on_node` asserts +#: exactly that and the bodies run out of `/runs//zones/…/package` +#: by absolute path. A variable that only moves the parent leaves the operator +#: composing the real answer in their head, and a container bind mount has to +#: name the whole path anyway. +#: +#: `INFERA_` and not `AGENT_SYS_`: the `AGENT_SYS_*` namespace is what `env_mgr` +#: *publishes to a body* — `AGENT_SYS_MY_ZONE`, `AGENT_SYS_MY_WORKSPACE`, +#: `AGENT_SYS_TASK_PACKAGE` — and one of those set by a caller is either ignored +#: or a collision. A variable read *from* the environment does not belong in a +#: namespace whose other members are written *to* it. +WORKROOT_ENV_VAR = "INFERA_AGENT_SYSTEM_WORKROOT" + + def default_root() -> Path: - """`$XDG_STATE_HOME/agent-sys-demo`, or `~/.local/state/...`. + """`$INFERA_AGENT_SYSTEM_WORKROOT`, else `$XDG_STATE_HOME/agent-sys-demo`. + + Two sources and one rule: the specific name wins over the generic base. A + caller who set neither gets `~/.local/state/agent-sys-demo`. State rather than cache or data: it is *"state that should persist between restarts but is not important enough for the data directory"*, which is what a demo run is. A cache directory would be correct until somebody cleared it between the interrupt and the resume. + + **An empty or relative value reads as unset.** `XDG_STATE_HOME`'s own + specification says exactly that of a base directory, and the reason applies + with more force here: a relative run root resolves against whatever `cwd` a + body inherited, which is the one thing `` may not depend on if the + compute node is to find the same directory. """ + named = os.environ.get(WORKROOT_ENV_VAR, "").strip() + if named and os.path.isabs(named): + return Path(named) base = os.environ.get("XDG_STATE_HOME") or str(Path.home() / ".local" / "state") return Path(base) / "agent-sys-demo" @@ -282,6 +320,53 @@ def confinement(availability: Availability | None = None) -> str: # Credentials +def _probe_environment() -> dict[str, str]: + """The ambient environment **plus** the o11y prefix's `CLAUDE_CONFIG_DIR`. + + Gate 1 covers *agent* children; this subprocess is not one, so it dropped a + JSONL into `~/.claude/projects` every run — measured. Copied, not replaced: + a bare `env={...}` strips `PATH`, and a probe that cannot run refuses the + whole run. Never into our own `os.environ`. + """ + env = dict(os.environ) + env[CLAUDE_CONFIG_ENV_VAR] = str(Prefix.resolve(os.environ).claude_home) + # The other half of the relocation, and `material.deploy` already carries + # the same line: moving `CLAUDE_CONFIG_DIR` moves away the block holding the + # endpoint and credentials, so a probe without this answers `Not logged in` + # and blames the machine. Masked on a host whose shell exports them; under + # `--docker` nothing does. Reserved keys stop it undoing the line above. + env.update(harness.harness_env()) + return env + + +#: Where the probe runs. Its own directory, because AgentsView names a project +#: after the session's cwd — resolving the git *main repository* when there is +#: one — so inheriting the caller's put ten identical probe transcripts into the +#: real `infera` project. A plain directory falls back to its basename, and +#: `probe` is what these sessions are. +PROBE_DIR = "probe" + + +def probe_cwd(prefix: Prefix) -> Path: + return prefix.state / PROBE_DIR + + +def _probe_cwd_or_none(prefix: Prefix) -> str | None: + """The probe's own directory, or `None` if we could not make one. + + **A cwd is not worth failing the run for.** `preflight_credentials` aborts + everything when it fails, and a child refuses a cwd that does not exist — + so an unwritable prefix must fall back to the old behaviour, not turn a + misfiled transcript into a dead deployment. + """ + try: + cwd = probe_cwd(prefix) + cwd.mkdir(parents=True, exist_ok=True) + except OSError: + return None + return str(cwd) + + def preflight_credentials(*, cli: str = BACKEND, timeout: float = 90.0) -> str: """Ask the backend whether it can run at all, **before any zone is built**. @@ -293,7 +378,12 @@ def preflight_credentials(*, cli: str = BACKEND, timeout: float = 90.0) -> str: `CredentialsMissing` carrying **stdout and stderr both** on failure. **It does not test what the run does, and saying so is the point.** This - runs `claude -p` *unconfined*, against the operator's own config directory. + runs `claude -p` *unconfined*, against the operator's own credentials — but + not their own config directory: `CLAUDE_CONFIG_DIR` points into + `~/.infera_agent_sys` like every other `claude` child we spawn, so the + transcript lands there. Measured to keep authentication working; see + `_probe_environment`. Not the relocation the table below is about. + A confined task gets a different arm: `material.deploy` points `CLAUDE_CONFIG_DIR` into the zone — correctly, it is what removed the `$HOME` grant — which also moves away the `env` block in `~/.claude/settings.json` @@ -330,6 +420,8 @@ def preflight_credentials(*, cli: str = BACKEND, timeout: float = 90.0) -> str: try: done = subprocess.run( # noqa: S603 — `binary` came from `shutil.which` [binary, "-p", "Reply with exactly one word: ready"], + env=_probe_environment(), + cwd=_probe_cwd_or_none(Prefix.resolve(os.environ)), capture_output=True, text=True, timeout=timeout, diff --git a/agent_sys/cli/events.py b/agent_sys/cli/events.py index 582447d9e..4c1465132 100644 --- a/agent_sys/cli/events.py +++ b/agent_sys/cli/events.py @@ -22,7 +22,7 @@ __all__ = ["SCHEMA_VERSION", "Event", "EventKind"] -SCHEMA_VERSION = "1.3" +SCHEMA_VERSION = "1.4" """The schema of the machine-readable stream. Criterion 14 makes this an interface: **bump it on any change to `EventKind`, @@ -68,6 +68,16 @@ one that was never declared, and the difference is the whole claim the run is making. +**1.4** — `O11Y_PANEL`. The AgentsView panel's URL, and the notice that its +binary was fetched for the first time, were `log.info` calls. **Nothing in this +repository configures `logging`**, so the root logger sits at `WARNING` with no +handler and both lines were discarded — while the o11y failure paths, being +`log.warning`, reached stderr through `logging.lastResort`. Failures were +visible and successes were not, and the tests did not notice because +`caplog.at_level("INFO")` forces the level from pytest's side. A fact the user +is meant to read belongs in the stream, which is the thing in this package +whose job is being read; `logging` here is for the operator's diary. + `docs/interfaces.md` §5.7: once the whole-system CLI wants the same stream, two artefacts share this constant with no bump policy. That is open. """ @@ -96,6 +106,7 @@ class EventKind(str, Enum): PERMISSIONS_DISABLED = "permissions_disabled" ZONE_PREPARED = "zone_prepared" ACCESS_DENIED = "access_denied" + O11Y_PANEL = "o11y_panel" # what this run did NOT check, and why. Absent is not the same as dropped. VALIDATION_DROPPED = "validation_dropped" diff --git a/agent_sys/cli/expectations.py b/agent_sys/cli/expectations.py index 85552da78..0d2fc65e3 100644 --- a/agent_sys/cli/expectations.py +++ b/agent_sys/cli/expectations.py @@ -240,10 +240,184 @@ def _demo_verdict(verdict: Any, handoff: Any) -> str | None: DEMO = ExpectationSet(_DEMO_PROMISES, _DEMO_DROPPED, _demo_task, _demo_verdict) +# --------------------------------------------------------------------------- # +# examples/demo-runtime-grounding-fail — deterministic grounding violation +# +# Pure-program version of demo's check_grounded failure. `transform` writes a +# summary containing "99999", which does not appear in the facts manifest. +# check_grounded detects the ungrounded numeral → FAIL → summary sealed INVALID +# → consume stuck in WAITING_HANDOFF. + + +def _rt_grounding_verdict_exists(registry: Any) -> bool: + """Did `check_grounded` record a verdict on a **summary** in this package?""" + store, handoff_mgr = registry.get("handoff_store"), registry.get("handoff_mgr") + return any( + verdict.validator == "check_grounded" + for hid in handoff_mgr.all_ids() + if handoff_mgr.get(hid).type == "summary" + for version in store.list_versions(hid) + for verdict in store.read_verdicts(hid, version) + ) + + +def _rt_grounding_consumer_exists(registry: Any) -> bool: + return any(task.closure == "consume" for task in registry.get("task_mgr").all()) + + +def _rt_grounding_task(task: Any) -> str | None: + if task.status is TaskStatus.WAITING_HANDOFF and task.closure == "consume": + return "consumer_waits" + return None + + +def _rt_grounding_verdict(verdict: Any, handoff: Any) -> str | None: + if ( + verdict.validator == "check_grounded" + and handoff.type == "summary" + and not verdict.result + ): + return "grounded_verdict_fails" + return None + + +_RT_GROUNDING_PROMISES = { + "grounded_verdict_fails": Expectation( + "check_grounded records a failing verdict on the summary", + _rt_grounding_verdict_exists, + ), + "consumer_waits": Expectation( + "consume ends the run still in WAITING_HANDOFF", + _rt_grounding_consumer_exists, + ), +} + +RT_GROUNDING = ExpectationSet( + _RT_GROUNDING_PROMISES, {}, _rt_grounding_task, _rt_grounding_verdict +) + + +# --------------------------------------------------------------------------- # +# examples/demo-runtime-fanout-partial — fan-out partial failure +# +# Three producers fan out; emit_b writes rows missing a required field. +# check_thing detects the missing key → FAIL → thing_b sealed INVALID → +# merge (needing all three) stuck in WAITING_HANDOFF. + + +def _rt_fanout_verdict_exists(registry: Any) -> bool: + """Did `check_thing` record a verdict on a **thing_b**?""" + store, handoff_mgr = registry.get("handoff_store"), registry.get("handoff_mgr") + return any( + verdict.validator == "check_thing" + for hid in handoff_mgr.all_ids() + if handoff_mgr.get(hid).type == "thing_b" + for version in store.list_versions(hid) + for verdict in store.read_verdicts(hid, version) + ) + + +def _rt_fanout_merge_exists(registry: Any) -> bool: + return any(task.closure == "merge" for task in registry.get("task_mgr").all()) + + +def _rt_fanout_task(task: Any) -> str | None: + if task.status is TaskStatus.WAITING_HANDOFF and task.closure == "merge": + return "merge_waits" + return None + + +def _rt_fanout_verdict(verdict: Any, handoff: Any) -> str | None: + if ( + verdict.validator == "check_thing" + and handoff.type == "thing_b" + and not verdict.result + ): + return "thing_b_verdict_fails" + return None + + +_RT_FANOUT_PROMISES = { + "thing_b_verdict_fails": Expectation( + "check_thing records a failing verdict on thing_b", + _rt_fanout_verdict_exists, + ), + "merge_waits": Expectation( + "merge ends the run still in WAITING_HANDOFF", + _rt_fanout_merge_exists, + ), +} + +RT_FANOUT = ExpectationSet( + _RT_FANOUT_PROMISES, {}, _rt_fanout_task, _rt_fanout_verdict +) + + +# --------------------------------------------------------------------------- # +# examples/demo-runtime-disagree — body succeeds, content disagrees +# +# Two reviewers produce well-formed reviews but disagree on student_a. +# reconcile merges them (exit 0), check_agree detects the non-empty +# disagreement list → FAIL → review sealed INVALID → downstream stuck. + + +def _rt_disagree_verdict_exists(registry: Any) -> bool: + """Did `check_agree` record a verdict on a **review**?""" + store, handoff_mgr = registry.get("handoff_store"), registry.get("handoff_mgr") + return any( + verdict.validator == "check_agree" + for hid in handoff_mgr.all_ids() + if handoff_mgr.get(hid).type == "review" + for version in store.list_versions(hid) + for verdict in store.read_verdicts(hid, version) + ) + + +def _rt_disagree_downstream_exists(registry: Any) -> bool: + return any(task.closure == "downstream" for task in registry.get("task_mgr").all()) + + +def _rt_disagree_task(task: Any) -> str | None: + if task.status is TaskStatus.WAITING_HANDOFF and task.closure == "downstream": + return "downstream_waits" + return None + + +def _rt_disagree_verdict(verdict: Any, handoff: Any) -> str | None: + if ( + verdict.validator == "check_agree" + and handoff.type == "review" + and not verdict.result + ): + return "agree_verdict_fails" + return None + + +_RT_DISAGREE_PROMISES = { + "agree_verdict_fails": Expectation( + "check_agree records a failing verdict on the merged review", + _rt_disagree_verdict_exists, + ), + "downstream_waits": Expectation( + "downstream ends the run still in WAITING_HANDOFF", + _rt_disagree_downstream_exists, + ), +} + +RT_DISAGREE = ExpectationSet( + _RT_DISAGREE_PROMISES, {}, _rt_disagree_task, _rt_disagree_verdict +) + + #: Package directory name -> what that package promises. **Absent means `EMPTY`**, #: so `examples/demo2` needs no entry: it promises that nothing will fail, which #: is a complete statement and the one its run makes. -_BY_PACKAGE: dict[str, ExpectationSet] = {"demo": DEMO} +_BY_PACKAGE: dict[str, ExpectationSet] = { + "demo": DEMO, + "demo-runtime-grounding-fail": RT_GROUNDING, + "demo-runtime-fanout-partial": RT_FANOUT, + "demo-runtime-disagree": RT_DISAGREE, +} def for_package(root: Path) -> ExpectationSet: diff --git a/agent_sys/cli/main.py b/agent_sys/cli/main.py index 0acd84a24..4940b7019 100644 --- a/agent_sys/cli/main.py +++ b/agent_sys/cli/main.py @@ -21,9 +21,10 @@ import argparse import logging +import os import shutil import sys -from collections.abc import Sequence +from collections.abc import Callable, Sequence from contextlib import ExitStack from pathlib import Path from typing import Any, TextIO @@ -46,9 +47,20 @@ from cli.render.machine import JsonLinesRenderer from cli.stream import Stream from env_mgr import meta +from env_mgr.o11y.agentsview import ( + RECIPE_PATH, + freshly_installed, + ensure_installed, + ensure_run_project, + ensure_running, + pinned_version, + resolve_port, +) +from env_mgr.prefix import Prefix from env_mgr.prepare import EnvManager, permissions_enforced from env_mgr.protocols import NoConfinement, PrepareRefused, UnresolvedGrant from env_mgr.remote.connection import sync_transport +from env_mgr.servers import REGISTRY_ENV_VAR, owned_servers from env_mgr.sync import check_delete_scope from monitor import ( NullUserSink, @@ -173,6 +185,61 @@ def parser() -> argparse.ArgumentParser: "ends in seconds regardless; this only bounds one that never stops" ), ) + run.add_argument( + "--agentsview-port", + type=int, + default=None, + metavar="N", + help=( + "port for the AgentsView o11y panel (default 18888; " + "a port already in use is a warning and a skip)" + ), + ) + run.add_argument( + "--no-agentsview", + action="store_true", + help="do not start the AgentsView o11y panel", + ) + # Docker mode: run inside a container managed by env_mgr. + run.add_argument( + "--docker", + action="store_true", + help="run inside a Docker container (env_mgr builds and starts it automatically)", + ) + run.add_argument( + "--docker-debug", + action="store_true", + help="start the container and drop into an interactive shell (sleep infinity), no task is run", + ) + run.add_argument( + "--docker-image", + metavar="IMAGE", + default="infera/agent-sys:latest", + help="Docker image to use (default: infera/agent-sys:latest)", + ) + run.add_argument( + "--docker-name", + metavar="NAME", + default="agent-sys-container", + help="container name (default: agent-sys-container)", + ) + run.add_argument( + "--docker-rm", + action="store_true", + help="remove the container after the run finishes (default: keep running)", + ) + run.add_argument( + "--detect-and-copy-host-ssh-config", + action=argparse.BooleanOptionalAction, + default=True, + help="mount host ~/.ssh into the container (default: on)", + ) + run.add_argument( + "--detect-and-copy-host-claude-config", + action=argparse.BooleanOptionalAction, + default=True, + help="mount host ~/.claude into the container (default: on)", + ) return top @@ -209,7 +276,34 @@ def main(argv: Sequence[str] | None = None) -> int: try: if args.verb == "show": return _show(args, stream) - return _run(args, stream) + # The one call site: the daemon outlives the run, so it starts + # once per invocation and its result never reaches the exit code. + # Not for `--dry-run` (whose contract is *resolve everything, do + # nothing*) or `--clean` (which deletes every run and exits). + # + # **And not for `--docker`, where the panel belongs in the + # container.** A panel can only ingest transcripts it can see, and + # under `--docker` the run writes them into the *container's* + # prefix, which is not mounted on the host. Starting one here + # produced a silent triple failure, measured: the host daemon took + # the port, the container's `ensure_running` then skipped with + # *"port N is in use by something else"* -- true, and misleading, + # because the something else was us -- and the run's transcripts + # were ingested by nobody. Host database 0 sessions / 0 messages, + # container database never created, two real .jsonl transcripts + # (157 KB and 54 KB) sitting unread in the container prefix. + # + # The switch is `--docker`, not the forwarded `--no-agentsview`, + # because that flag says *this operator wants no panel* and here we + # want one -- just on the other side of the container wall. It is + # forwarded below so the operator keeps both answers. + in_container = getattr(args, "docker", False) or getattr(args, "docker_debug", False) + panel_url = _start_o11y( + args.agentsview_port, + disabled=args.no_agentsview or args.dry_run or args.clean or in_container, + stream=stream, + ) + return _run(args, stream, stack, panel_url) except package.PackageNotFound as exc: return _fail(stream, PRECONDITION, str(exc)) except SpecInvalid as exc: @@ -226,6 +320,110 @@ def main(argv: Sequence[str] | None = None) -> int: return UNEXPECTED_FAILURE # pragma: no cover — ExitStack always returns above +def _install_item(prefix: Prefix) -> Callable[[], Sequence[Any]]: + """The recipe call `ensure_installed` injects rather than performs. + + **This is o11y-shaped code living in `cli/`, and review asked why. It is + here because it cannot be under `env_mgr/`.** Spec §9 walls `recipe`, + `runner` and `installers` off from every module there, and + `tests/env_mgr/test_imports.py` enforces it structurally — it derives the + "above the wall" set from the filesystem and walks it with `rglob`, so a new + subpackage is covered the moment it exists, with `env_mgr/cli.py` the single + exemption. This function's whole body is `load_recipe` + `runner.run`, so + any home under `env_mgr/o11y/` fails that test. A first draft of + `ensure_installed` did exactly that and failed exactly that test, which is + why it takes an injected callable rather than looking the recipe up itself. + + Moving it would mean either putting it in `env_mgr/cli.py` — legal, but that + is env_mgr's command-line entry point and it would be there for the + exemption rather than because it belongs — or widening the exemption, which + weakens a guard whose own docstring records a module going unchecked when + the list was maintained by hand. Neither is this function's call to make. + + **Zero-argument, not a precomputed list**: a list evaluated at the call site + would run the installer before `--dry-run` could stop it. `target.path` is + overridden because the checked-in recipe's value is a placeholder — nothing + in `env_mgr` expands `${VAR}` in a YAML value. + """ + + def call() -> Sequence[Any]: + from env_mgr.recipe import load_recipe + from env_mgr.runner import Filters, run + + target, items = load_recipe(RECIPE_PATH) + target.path = str(prefix.root) + outs, _status = run(target, items, "install", Filters(item="agentsview")) + return outs + + return call + + +def _start_o11y( + port_flag: int | None, disabled: bool, stream: Stream | None = None +) -> str | None: + """The one call site. Returns the panel URL, or None, and never raises. + + **Also o11y-shaped code in `cli/`, and also deliberate.** Review asked for + it to live in `env_mgr/o11y/`, and the destination is right; the move is a + refactor rather than a relocation, because this function is tied to `cli/` + at two points. It calls `_install_item`, which cannot leave (see there). And + it emits on the `Stream`, so moving it as written would have `env_mgr` + importing `cli` — a library importing its own consumer, which is a worse + inversion than the one being fixed. The honest shape is + `start_panel(prefix, port_flag, install_item, announce)` in the o11y package + with a four-line adapter here, and it re-points the ten or so tests that + patch `cli_main.ensure_installed` / `cli_main.ensure_running` at module + level. Worth doing on its own, not folded into a review-fix. + + **The bare `except Exception` is the point**: everything inside + `ensure_running` already degrades to a warning, and this catches what that + module has not thought of. A side-car that can abort a run is a worse bug + than a missing panel. + + **Success goes to the `stream`, failure to `logging`.** Both were + `log.info`, and this package never configures `logging` — so the root + logger sits at `WARNING` with no handler and they reached nobody, while the + warnings still reached stderr through `lastResort`. `stream` is optional + because the failure-mode tests are not about it; `main` always passes one. + + `os.environ` is read here and never written. + """ + if disabled: + return None + + def say(message: str, **fields: Any) -> None: + if stream is not None: + stream.emit(EventKind.O11Y_PANEL, message, **fields) + + try: + prefix = Prefix.resolve(os.environ) + installed = ensure_installed(prefix, _install_item(prefix)) + if not installed.running: + # `ensure_installed` has already logged the one warning. Starting a + # daemon whose binary is absent would only add a second. + return None + if freshly_installed(installed.reason): + # Only on the run that downloaded: a line on every run is how a + # real warning gets scrolled past. Says what arrived and where, + # because a 45 MB download nobody asked for should be inspectable. + version = pinned_version() + path = str(prefix.bin / "agentsview") + message = ( + f"fetched the o11y panel binary (agentsview v{version}, " + f"from github.com/kenn-io/agentsview) into {path}" + ) + log.info("agentsview: %s", message) + say(message, version=version, path=path, installed=True) + status = ensure_running(prefix, port=resolve_port(port_flag, os.environ)) + if status.running: + log.info("agentsview: o11y panel at %s", status.url) + say(f"panel at {status.url}", url=status.url) + return status.url + except Exception as e: # noqa: BLE001 + log.warning("agentsview: o11y start-up failed (%s); continuing without a panel.", e) + return None + + def _fail(stream: Stream, code: int, message: str, *, kind: EventKind | None = None) -> int: stream.emit(kind or EventKind.RUN_COMPLETE, message, exit_code=code, ok=False) return code @@ -259,12 +457,117 @@ def _show(args: argparse.Namespace, stream: Stream) -> int: # run -def _run(args: argparse.Namespace, stream: Stream) -> int: +def _run( + args: argparse.Namespace, + stream: Stream, + stack: ExitStack, + panel_url: str | None = None, +) -> int: if args.clean: return _clean(args, stream) + if getattr(args, "docker_debug", False): + return _docker_debug(args, stream) + if getattr(args, "docker", False): + return _docker_run(args, stream) if args.dry_run: return _dry_run(args, stream) - return _real_run(args, stream) + # `stack` reaches only `_real_run`: it is what stops the servers a run + # started, and the other two verbs start none. `clean` removes a directory; + # `dry-run` dispatches nothing, which its own body asserts rather than + # assumes. + return _real_run(args, stream, stack, panel_url) + + +def _docker_debug(args: argparse.Namespace, stream: Stream) -> int: + """Start a container with sleep infinity and drop into a shell.""" + from env_mgr.container import ContainerManager + + mgr = ContainerManager( + image=args.docker_image, + container_name=args.docker_name, + detect_ssh=args.detect_and_copy_host_ssh_config, + detect_claude=args.detect_and_copy_host_claude_config, + ) + + mgr.start() + + name = mgr.container_name + stream.emit( + EventKind.RUN_COMPLETE, + f"container '{name}' is running (sleep infinity). Connect with:\n" + f"\n" + f" docker exec -it {name} bash\n", + exit_code=OK, + ok=True, + ) + + import subprocess + return subprocess.run( + [mgr._docker_bin(), "exec", "-it", "-w", "/opt/Infera", name, "bash"], + ).returncode + + +def _docker_run(args: argparse.Namespace, stream: Stream) -> int: + """Delegate the run to a Docker container managed by env_mgr.""" + from env_mgr.container import ContainerManager + + mgr = ContainerManager( + image=args.docker_image, + container_name=args.docker_name, + detect_ssh=args.detect_and_copy_host_ssh_config, + detect_claude=args.detect_and_copy_host_claude_config, + ) + + stream.emit( + EventKind.RUN_COMPLETE, + f"starting Docker container (image={mgr.image})", + exit_code=OK, + ok=True, + ) + + mgr.start() + + # Forward the original command into the container, stripping --docker flags. + forwarded = ["agent-sys", "run"] + if args.package: + forwarded += ["--package", args.package] + if args.demo_root: + forwarded += ["--demo-root", args.demo_root] + if args.dry_run: + forwarded += ["--dry-run"] + if getattr(args, "with_broken", False): + forwarded += ["--with-broken"] + if getattr(args, "resume", False): + forwarded += ["--resume"] + if getattr(args, "allow_repo_config", False): + forwarded += ["--allow-repo-config"] + for var_item in args.var: + forwarded += ["--var", var_item] + if args.json: + forwarded += ["--json", args.json] + # The panel runs inside the container -- `main` skips the host one under + # `--docker` -- so both o11y answers have to cross the wall or the operator + # loses them. Without the port there is no route at all to move the panel + # off a busy 18888: `--agentsview-port` stopped here, and `container.py` + # forwards no `AGENTSVIEW_PORT` either, so a host with anything on that + # port gave a containerised run no panel and no way to ask for one. + if getattr(args, "no_agentsview", False): + forwarded += ["--no-agentsview"] + if getattr(args, "agentsview_port", None) is not None: + forwarded += ["--agentsview-port", str(args.agentsview_port)] + + rc = mgr.exec(forwarded, workdir="/opt/Infera") + + if getattr(args, "docker_rm", False): + mgr.stop() + stream.emit( + EventKind.RUN_COMPLETE, + f"container '{mgr.container_name}' removed", + exit_code=rc, + ok=rc == 0, + ) + + return rc def _clean(args: argparse.Namespace, stream: Stream) -> int: @@ -326,7 +629,12 @@ def _layout(args: argparse.Namespace) -> Layout: return layout_for(root).create() -def _real_run(args: argparse.Namespace, stream: Stream) -> int: +def _real_run( + args: argparse.Namespace, + stream: Stream, + stack: ExitStack, + panel_url: str | None = None, +) -> int: """Everything. Needs credentials, a sandbox, and a model. The order of the two preconditions is measured rather than aesthetic: the @@ -351,17 +659,31 @@ def _real_run(args: argparse.Namespace, stream: Stream) -> int: promises = expectations.for_package(package.locate(args.package)) layout = _layout(args) + # **Here, and not in `_start_o11y`, because the run id does not exist yet + # when the panel starts.** Before any task runs, so the mapping is in place + # before the first transcript is ingested -- measured: a mapping that + # exists at ingest labels the session at sync time, with no second call. + mapped = ensure_run_project(panel_url, layout.run) + if mapped.running: + stream.emit( + EventKind.O11Y_PANEL, + f"this run is project {mapped.reason!r} on the panel", + project=mapped.reason, + run=str(layout.run), + ) + # The servers this run starts are stopped when this block unwinds; `env_mgr` + # starts them, so `env_mgr` stops them. The path is set on `os.environ` + # because that is the only channel a recipe subprocess reads it from; it is + # a per-run constant, safe even though the runner is threaded. + registry_file = layout.run / "servers.json" + os.environ[REGISTRY_ENV_VAR] = str(registry_file) + stack.enter_context(owned_servers(registry_file)) root = package.locate(args.package) - # **Read once, at start-up, and it is the run's fact rather than a task's.** - # `env_mgr.prepare.permissions_enforced()` is the single reader of the - # variable and this calls it; the demo never learns the name. A function and - # not a constant, on their advice: a module-level read is taken at import - # and would answer with whatever the environment held then. - # - # `main` has no `Prepared` at all — a non-leaf never executes — so a banner - # about the run cannot be assembled from per-task facts even where they - # exist. `Prepared.permissions_enforced` stays a confirmation, not a second - # source. + # Read once, at start-up: this is the run's fact rather than a task's. + # `env_mgr.prepare.permissions_enforced()` is the variable's single reader. + # A function and not a constant, because a module-level read is taken at + # import and would answer with whatever the environment held then. + # `Prepared.permissions_enforced` stays a confirmation, not a second source. enforced = permissions_enforced() stream.emit( EventKind.CONFINEMENT_APPLIED, @@ -398,28 +720,34 @@ def _real_run(args: argparse.Namespace, stream: Stream) -> int: # which is how the bug was found. One call now. running = start_monitors(registry) try: - if args.resume: - resume_all(registry) - else: - _start(registry, stream) - _settle(registry, stream, timeout=getattr(args, "timeout", None) or _SETTLE_TIMEOUT) + try: + if args.resume: + resume_all(registry) + else: + _start(registry, stream) + _settle(registry, stream, timeout=getattr(args, "timeout", None) or _SETTLE_TIMEOUT) + finally: + # Names that did **not** come back, rather than a hang or a silent pass. + stragglers = running.stop(timeout=5.0) + if stragglers: + stream.emit( + EventKind.RUN_COMPLETE, + f"monitor loops that did not return: {sorted(stragglers)}", + stragglers=sorted(stragglers), + ok=False, + ) + # Described AFTER the run, not before it: the subgraph does not exist + # until the root's main phase unfolds, so a graph printed at submit time + # would be one task long. + tasks = registry.get("task_mgr").all() + _emit_graph(stream, tasks, resumed=bool(args.resume)) + _describe(registry, tasks, stream) + return _report(registry, stream, layout, promises) finally: - # Names that did **not** come back, rather than a hang or a silent pass. - stragglers = running.stop(timeout=5.0) - if stragglers: - stream.emit( - EventKind.RUN_COMPLETE, - f"monitor loops that did not return: {sorted(stragglers)}", - stragglers=sorted(stragglers), - ok=False, - ) - # Described AFTER the run, not before it: the subgraph does not exist - # until the root's main phase unfolds, so a graph printed at submit time - # would be one task long. - tasks = registry.get("task_mgr").all() - _emit_graph(stream, tasks, resumed=bool(args.resume)) - _describe(registry, tasks, stream) - return _report(registry, stream, layout, promises) + # Monitor decisions and reporting may reuse a settled executor. Once + # both are over, every attempt belongs to this invocation and must be + # disposed before Python tears down the SDK's private event loops. + registry.get("runner").shutdown() # --------------------------------------------------------------------------- # diff --git a/agent_sys/cli/render/human.py b/agent_sys/cli/render/human.py index dffe19264..a199fcc05 100644 --- a/agent_sys/cli/render/human.py +++ b/agent_sys/cli/render/human.py @@ -35,6 +35,7 @@ EventKind.PERMISSIONS_DISABLED: "NO SANDBOX", EventKind.VALIDATION_DROPPED: "DROPPED", EventKind.ZONE_PREPARED: "zone", + EventKind.O11Y_PANEL: "o11y", EventKind.ACCESS_DENIED: " denied", EventKind.TASK_DISPATCHED: "dispatch", EventKind.PHASE_START: " phase", diff --git a/agent_sys/docs/ROADMAP.md b/agent_sys/docs/ROADMAP.md index b14ea406a..ed545deb7 100644 --- a/agent_sys/docs/ROADMAP.md +++ b/agent_sys/docs/ROADMAP.md @@ -212,6 +212,7 @@ how much of the verdict it sees — both alpha-dependent, neither measured. | **Agent env reuse** | A task, or a validation phase, reusing an existing agent environment directly or with light modification. **Careful:** this must not blur the system's isolation standard, which is the whole reason each validation gets a fresh environment | | **Movable handoff storage** | A handoff record should carry enough detail to be trackable *and* be movable between storage locations. The alpha does the first and not the second | | **Sync direction and conflict** | The weak local↔remote mapping is `rsync`, which has a direction. Which side wins when both changed is unspecified, and "the caller decides" will lose data eventually | +| **If `agent_sys` must ever serve MCP itself** — *the **component-supplied** in-process route was deleted 2026-09-04; `remote/tools.py` stays as a named exception, and this row is its closing condition* | The owner's sketch, in their words: **一个单独的线程,在初始化的时候load起来** — a separate thread, loaded at init — **with the declaration installed into Claude as a plugin.** What was deleted is different and is not what this describes: a component's `tools/*.tooldef.py` ran **that component's Python inside the supervisor process**, so a third party's code shared an address space with the process holding the run's credentials, and there was no boundary to configure. **Only the component-supplied half went.** `ToolDef` itself is still defined and still running, in `env_mgr/remote/tools.py`, which is the one thing `Prepared.tools` now carries. A thread is still in-process and would not fix that on its own; what makes the sketch tolerable is that the *declaration* goes through the plugin mechanism like every other capability, so there is one install route and no parallel one. **Why it is not needed now:** an add-on that wants to offer a tool ships a standalone MCP server, and both transports are already served — **stdio** is spawned by the harness from a declaration, and **port-based** is started by `env_mgr` via the `run_server` installer (`env_mgr/installers/run_server.py`). Nothing an add-on can express today requires `agent_sys` to be the server. Revisit only if something does. **How this touches the one route that was kept:** `spec.provisioning.md` §6 leaves `env_mgr/remote/tools.py` as a standing exception — `env_remote_run` / `_push` / `_pull` are injected into `ClaudeAgentOptions` as a live object with nothing written to disk, so no installer can carry them — and its **closing condition** is reproviding those three as a standalone server started by `run_server`. That is the same `run_server` this row argues already suffices, so the exception closes on the route above and **not** on the sketch: building the thread is not what retires it. Until somebody does that work, §6 has exactly one exception and a second added by analogy is the rule being ignored | ## 6.1 **P0 RISK — an AI task is not confined, and confinement is the anti-cheating property** diff --git a/agent_sys/docs/TODO.md b/agent_sys/docs/TODO.md index 8e7329d28..25ff9f86c 100644 --- a/agent_sys/docs/TODO.md +++ b/agent_sys/docs/TODO.md @@ -29,6 +29,12 @@ from where) are the four that block something real. | 4a | **A package's layout must separate a task's `bin` from the validators' `bin`** — *user-owned, and it is the precondition for staging* | **F19 reversed to staging** (`interfaces.md` §4.16), so a task gets a copy of what it needs rather than a grant on the package root. That only closes `env_mgr` criterion 13 if **a task's executable set can be named without dragging `validators/` along** — which is a package-layout guarantee, not something `env_mgr` can enforce. The user owns it. Until it holds, staging moves the leak rather than closing it | +| 4j | **A run killed by a signal leaks its servers, and nothing ever reaps the registry** — *recorded with `run_server`, 2026-09-04* | `env_mgr/servers.py` guarantees exactly *"stopped on normal and handled-error exit"* — whenever `owned_servers` unwinds. It does **not** unwind on `SIGTERM` (no handler is installed) or on `SIGKILL`. **What it costs to leave undone**: a crashed or timed-out run leaves a **listening process** and a registry file that no later run reads, so the port stays taken for as long as the machine is up, and the only thing that will ever notice is the *next* run's port check — which by then can only report a conflict, because the holder is a stranger to it. On a shared host the leak is somebody else's problem before it is ours. **Two separable pieces, and the first is the cheap one.** (1) A **sweep at start-up**: read registries left under earlier run roots and stop anything whose `starttime` still matches, which needs no new mechanism and reuses `stop_all` unchanged. (2) Closing the `SIGKILL` case itself, which the sweep does not do. `prctl(PR_SET_PDEATHSIG, SIGTERM)` was **measured to work** on this host — with it a child died when its parent was `kill -9`'d, without it the child survived — but it is the wrong tool at the site that spawns these: the spawning process is the recipe child, which exits within seconds, so the server would die the moment its own install finished. It would need the supervisor to be the direct parent, which is an architecture change and not a flag. **Deliberately not built with `run_server`**: a sweep that reaps the wrong thing is worse than a leak, and deciding which run roots it may reach is an owner's call about scope, not an implementation detail | + +| 4k | **Installs run unconfined, and `env_mgr` §4 does not say so** — *measured 2026-09-04, recorded only; nothing changed and nothing should be* | `env_mgr`'s design makes confinement the load-bearing property, and **installs are an exception to it.** Measured while siting the server registry: `agent_assets.py::_run_cmd` is `subprocess.run(list(argv), capture_output=True, text=True, env=dict(environ), timeout=timeout)` — **no `preexec_fn`, no Landlock ruleset, and the full inherited environment** — so `python -m env_mgr bootstrap `, and every `run:` string an installer shells from it, executes with the supervisor's own reach. Nothing about that is accidental or obviously wrong: **an install writes outside every zone by definition**, which is what installing is, and confining it to a zone would defeat the purpose rather than harden it. The gap is documentary. §4 reads as though confinement is universal within `env_mgr`, and the one path that is deliberately outside it is named nowhere, so the next reader meets the exception by discovering it in `_run_cmd` rather than by being told. **What would close it: a sentence in §4 naming the install path as out of scope for confinement, and why.** Not a code change — recorded here rather than acted on, because the behaviour is pre-existing, arguably required, and this round is not the place to relitigate it | + +| 4l | **One fact, two readers, two different fields — and the disagreement was silent** — *instance fixed, class unrecorded until now, 2026-09-04* | *"Does this zone have a far side, and where?"* was answered in two places from two fields. `prepare.py:645 _remote_tools` read **`far_roots`**; the `paths.zone_env` call site forty lines above read **`ctx.mapping`**, which is **weak-only** because it is `sync`'s input and strength answers *must bytes be copied*. A **strong** mapping still has a far side and its `remote_root` is not in `ctx.mapping` at all. **Result: the agent was handed `env_remote_run`/`push`/`pull` pointed at a far side, and not one `AGENT_SYS_*_REMOTE` variable saying where it is.** The comment at `prepare.py:528-538` records that this was the configuration the accepted remote run used — **live, not latent**. The instance is fixed. **What is recorded here is the class**, because the fix was one call site and nothing prevents the third: a question with two answer sources, where one source is *nearly* right, fails by omission rather than by raising, and omission is what `AGENT_SYS_*_REMOTE` does — no variable, no error, an agent that improvises a path. **What would close it**: `_far_side(ctx)` at `prepare.py:621` already exists and reads *both* fields; if every consumer of "where is the far side" went through it, there would be one reader. Nobody has checked whether any consumer still does not | + ## Unowned, reported more than once, recorded so they do not go stale silently Each of these has been raised by a package that does not own it and has stayed @@ -37,11 +43,92 @@ unclaimed. **Not blocked on anyone — nobody has them.** | # | Item | Why it is not already fixed | |---|---|---| | 4b | **A typo'd `kind` in `Task.kinds` is caught by nothing at runtime** | `_participates` turns it into a no-op, and §4.16's narrowing removed the last place it would have raised. Probably `closure` check 6, at load time. **Reported twice by `env_mgr`, still unowned** | -| 4c | **`examples/demo/logic/store.py`'s F-D5 fallback cannot work under confinement** | It reads `AGENT_SYS_DEMO_STORE` and walks to a manifest; `env_mgr`'s `p11` measured **EACCES on the store root from a confined body**. `demo`'s file, with a second defect in it that is `handoff`'s. **The declared route is `materials.json`** — reaching a non-target artefact means *declaring* it (`inputs: ['summary', 'facts']`, permitted by spec §4.1's many-to-many binding), which also makes the phase record a verdict against the second artefact. **A design question for `demo` and `validator`, not a patch** | +| 4c | **P0 — a validator cannot reach the artefact its target was produced *from*, so five task packages scan the store instead** — *and the route this row previously proposed does not exist* | Was scoped to `examples/demo/logic/store.py`; the file is now `examples/demo/assets/lib/store.py` and **five copies** of it (`demo`, `demo2`, and three under `examples/llm_e2e_performance_optimization/`). See below | | 4d | **`test_a_gate_failure_does_not_deadlock_the_next_dispatch` fails 2 runs in 4** | Green alone and green in its own file. **No cause offered** — and the day's rule applies: a red suite in a shared worktree is not evidence about anyone's change. With `agent-mod-2`. **2026-08-29, end of day: 4 full-suite runs, 4 green** (`1905 passed, 3 skipped, 4 xfailed`, ~64 s each). **Not "fixed" — the worktree was quiet, so the trigger may simply have been absent**, which is the converse of the rule above and the same instrument problem. Running it alone proves nothing and was already known not to; recorded so the next person starts from four data points rather than repeating the isolated run | | 4e | **A hole in the store has no reaper** | §4.14 makes holes permanent and never renumbered by design. Whether they should ever be collected is undecided, not deferred | | 4f | **`check_grounded` has never been observed catching anything** — *ruled parked 2026-08-29, deliberately not worked* | Criterion 10 aims to show a validator catching an ungrounded number; three end-to-end runs showed a good model **declining to fabricate one** instead, so the validator's **failing** direction — what its `strong` claim is about — has never executed. **The user's ruling: not a framework question and not a principle question, this is `check_grounded`'s own business semantics, and it is not worth the time.** The shape they suggested if anyone ever picks it up: **split it in two** — one validator over the other fields, and a second that judges only whether the agent's answer about the missing duration is *reasonable*, passing if it is. **Two measurements bear on any such build:** `check_grounded` matches `\d+`, *"digits, not a parser"*, so `256` reads as grounded via `sha256_prefix` — the grounding set is **wider than what the facts assert**, and a fabricated number landing inside any digit run in the copied facts passes anyway. And `logic/check_grounded/readme.md` named the `UNEXPECTED_SUCCESS`/exit-3 outcome in advance, so **exit 3 is the artefact working, not a fault to repair** | +| 4g | **The backend's `claude` child processes do not exit when their task completes** — *first report, 2026-09-04, measured not inferred* | Seen while watching an `examples/demo2` run for an unrelated reason. Nine `claude` CLI processes alive at once, one per agent task, **elapsed 6 to 26 minutes and holding 5–11 seconds of CPU each, all sleeping (`S`)**. The oldest was `directions`, which the run log showed completing 26 minutes earlier; `ps -o pid=,stat=,etime=,time=` is the whole measurement. So they are not working and not being reaped — they accumulate for the life of a run, one per task. Harmless on `demo2`; a package with many tasks, or a long-lived supervisor, is where it stops being harmless. **Whose it is, is the open part**: it could be `claude-agent-sdk` not closing its transport, or `agent/backends/claude_sdk.py` not disposing the client after the result arrives. Nothing narrows it yet, and nobody has claimed it. What would close it: run one AI task, capture the child pid, and watch whether it exits when the SDK returns — if it does, the leak is in how the runner holds the client, not in the CLI | + +| 4n | **The `assets/` mechanism resolves entry points and pretends to be a resource mechanism** — *user-owned, small-scope refactor wanted (PR 155 review, 2026-09-04)* | `spec_loader/assets.py` finds **one file per role**: `body.readme` and `body.entry`, by filename convention, scoped by an optional `[.]/` folder. **Every other file an object needs is carried by nothing.** They arrive because `layout.stage_package(include=None)` copies the **whole package** into the zone, and a body reaches them by hand-built path — `exec python3 "$AGENT_SYS_TASK_PACKAGE/assets/check_x.validator/check.py"` is the pattern in every shipped validator. So a validator's `check.py`, its `readme.md` and any shared `assets/lib/*.py` ride along on a copy that is not the assets mechanism, and `body.entry` is a **pointer, not a manifest**. Two consequences: the object's resource set is never named anywhere, so `TODO.md` 4a (naming a task's executable set) cannot be answered from the assets index; and each body re-derives the same path string, so a layout change breaks them one by one at run time rather than at load. **The user's ruling: the mechanism is implemented badly and wants a small-scope refactor** — not a rewrite. Surfaced when `agent` needed its *directory* rather than a file and `fill_body` had no answer, which is `assets.py`'s own recorded gap (*"two of the four kinds have no body — a gap, not an omission"*), closed for `agent` by `resolve_folder` while leaving the resource question untouched | +| 4m | **The stdio MCP servers hand-roll the protocol instead of using `mcp.server`** — *asked for in the PR 155 review, 2026-09-04, not built* | `env_mgr/addons/envchk-baseline/.claude/servers/envchk_baseline_server.py` and the package's `envchk_stdio.mcp.py` speak JSON-RPC over stdin/stdout directly. The review asked for the standard library instead, and that is the right shape. **What blocks it is the interpreter, not the code**: these servers are launched as `python3