chore(models): move perception ONNX models out of git to a pinned Release - #492
chore(models): move perception ONNX models out of git to a pinned Release#492idootop wants to merge 23 commits into
Conversation
…ease 感知的 5 个 ONNX 模型(合计 78MB)从 git 移出,改为托管在固定 tag `models` 的 GitHub Release,按 sha256 锁定后由脚本拉取。 ## 为什么 模型躺在 git 历史里有两笔持续成本:CI 的 `actions/checkout` 默认浅克隆,每跑一次 就白付一遍 78MB;以后每换一次模型都在历史里永久叠一份。改成 Release 资产后这两笔 都归零,且下载源可以走镜像。 (顺带纠正一个前提:这些模型从来不是 LFS 管理的,是根 commit 里的普通 blob,所以 不存在 LFS 流量超限的问题。) ## 怎么拿模型 - `scripts/models.lock.json` —— 唯一真源:`release_tag` / `base_url` / `mirrors` + 每个文件的 size、sha256、是否必需 - `scripts/fetch_models.py` —— 纯标准库下载器(不需要 uv:build.sh、CI、 install-hermes.sh 都直接 `python3` 调)。原子写(`.part` → `os.replace`)、 Range 续传、每源 3 次退避重试、size 快筛后再 hash。退出码 0/1/2 - `scripts/publish_models.sh` —— 维护者换模型:`upload <dir>` 上传并同步 lock, `refresh-lock` 按 Release 现状重算 lock 下载源 = `base_url`(GitHub 直连)+ `mirrors`(gh-proxy.com / gh-proxy.org / gh.idayer.com),与 `scripts/manifest.json` 的 `download.sites` 同源同序, 直连失败自动降级到镜像;`MILOCO_MODELS_BASE_URL` 可整体换源(内网 / 离线)。 ## 接入点 - `scripts/build.sh` 的 `pack_models`:无条件跑 `fetch_models.py --strict` (可选模型缺失也算失败,不能因一次网络抖动就发出少了 bge / VAD 的 tarball); tar 改为只打 `*.onnx` + `*.json`,把新增的 README.md 挡在归档外 - `ci.yml` / `release.yml`:`actions/cache`(key 只跟 lock 走)+ 显式下载步骤。 CI 必须下模型,否则 `requires_models` 那批用例会静默 skip、偷偷少跑一批覆盖 - `install-hermes.sh` step 4.7:本地现成 `.onnx` → cp;没有 → 按 lock 下载; 再失败 → warn 不中断。判据从"目录存在"改成"目录里真有 .onnx" - `local-ci.sh`:`--check --quiet` 非致命提示,防本地悄悄比 CI 少跑一批 终端用户与运行时不受影响:release 包仍带 `miloco-models-*.tar.gz`,install.py 解到 `$MILOCO_HOME/models/`;wheel 本来就不含该目录。 ## 验证 - 模型全部移走后跑 build:exit 0,5 个模型从真 Release 拉齐并逐个校验,`cmp` 与 原文件 5/5 byte-identical;tarball 成员与改动前逐一对齐 - 全量 backend pytest:3 failed / 2989 passed / **0 skipped**(3 项是 node_monitor smaps 的 macOS 已知失败,local-ci.sh 里显式容忍) - `test_fetch_models.py` 16 条契约测试;镜像兜底实测:直连域名打不通时自动切 gh-proxy.com 并通过 sha256 ## 已知取舍 `models` 是固定 tag、资产可变:手动换资产后老 commit 锁的 sha256 会对不上,需要跑 `publish_models.sh refresh-lock` 同步。git 历史里的旧 blob 未重写(另议)。
|
👋 感谢提交 PR @idootop!维护者会尽快 review。 提交前请确认:
|
pack_models 的 tar 依赖 shell glob 展开 `./*.onnx ./*.json`。上游 fetch_models.py
--strict 已经保证文件齐全,但万一那层被绕过(退出码被吞、步骤被跳过),bash 默认会
把没展开的字面量交给 tar,报 `tar: ./*.onnx: Cannot stat: No such file or directory`
—— set -e 一样会中止构建,只是排查的人得先反应过来那是个没展开的 glob。
改成 nullglob 下先点一次数,空了直接 die,报"模型目录为空 <路径>"。
顺手给同一函数里的 `$models_dir` 补上 `${}`:macOS 自带的 bash 3.2 会把紧跟变量的
全角「)」首字节吃进变量名,set -u 下这条错误路径自己会先炸成
`models_dir?: unbound variable`(C.UTF-8 / en_US.UTF-8 都复现)。
验证(bash 3.2):真实 models 目录 → guard 放行、归档成员与改动前逐字节同名
(5 个文件、`./` 前缀、不含 README.md);空目录 + stub fetch 返回 0 → 按预期
die 1 并打出中文提示。
ExWang
left a comment
There was a problem hiding this comment.
🟡 非阻塞(建议合并前顺手做,都是一行级)
1. build.sh:326 的 --strict 没传 --dest:校验的目录与打包的目录可分离
- 问题: dest 走
--dest / MILOCO_MODELS_DEST / _DEFAULT_DEST三级回退,而 :338/:348 的 guard 与 tar 打的是 :319 硬编码的$models_dir;build.sh 既不传--dest也不 scrub 环境。export 了MILOCO_MODELS_DEST时:源码 models/ 只剩 README →--strict在别处判通过 exit 0,:342die 1 "…--strict 本该已拦截"把排查方向带反;更糟一支是源码 models/ 留着与 lock 不符的旧 .onnx →--strict仍在别处通过、guard 数到 5 个放行 → 脏模型进 tarball 并被 bundle sha 背书,而 install.py:963 明写不再逐文件校验。同一 PR 的 install-hermes.sh:730 就显式传了--dest。 - 建议修法:
--strict --dest "$models_dir",并把 :342 的文案打出实际 dest;ci.yml:54 / local-ci.sh:57 一并显式化。
2. test_fetch_models.py 的 _run 全量继承 os.environ:按本 PR 自己的文档 export 换源变量后 10/16 测试失败且会真联网
- 问题: :42
env={**os.environ, **(env or {})}没剥MILOCO_MODELS_*,而_sources()对MILOCO_MODELS_BASE_URL是独占替换,fixture 的 file:// 假 Release 会被整体顶掉。该变量正是 models/README.md:25 与 dev-guide.md:390 推荐给内网/离线开发者的逃生口。内网开发者跑全量 pytest 会看到 10 条失败(test_empty_source_list_is_usage_error从 exit 2 翻成 exit 1、语义反转)、耗时 9.8s→58s,且这批测试会真的对公司镜像发请求,违反本文件自己的「不联网」契约。它还是「把换源逃生口接进 CI」这个缓解手段的前置条件。 - 建议修法:
base = {k: v for k, v in os.environ.items() if not k.startswith("MILOCO_MODELS_")}再合并;并给test_dest_env_is_honored同时传--dest并断言--dest压过 env(它现在不传,一旦该分支回归会把假模型写进真仓库那个已被 gitignore、git status 看不见的目录,CI 上还会被存进 actions/cache)。
3. ci.yml 缺 --strict(release.yml 有),残缺缓存被不可覆盖的 key 永久钉死
- 问题: 非 strict 下可选模型失败是「日志一行 + return 0」→ job 绿 → cache 把只有 4 个文件的目录存进
miloco-models-<hash(lock)>;primary key 命中即跳过 save,故该残缺缓存对这个 lock 版本永不自愈(只能等 lock 变更或 LRU 逐出),此后每次都要重下那 ~24MB,:53 步骤名承诺的「缓存命中时不联网」静默失效。今天覆盖率零损失(唯一 requires_models 只看两个必选模型),但一旦给 bge 去重或 VAD 加一条 model-gated 用例,就复活了本 PR 要消灭的静默 skip。边界:必选失败会正确红且不 save,故缓存永远至少含 det_4C + reid;_is_ready恢复后仍全量 hash,只可能「缺」不可能「错」。 - 建议修法:
--strict的 help 把语义限定为「打 release tarball 时用」,所以更贴合意图的是:保留非 strict 下载,在 pytest 之前补一步不联网的fetch_models.py --check --strict当门禁 —— 既不让第三方代理抖动把 CI 判红,又杜绝残缺状态过门。
4. install-hermes step 4.7 一级 cp 零校验,下载兜底挂在 elif 上永不执行
- 问题: 一级判据改成「目录里有任意 .onnx」(:701 compgen),命中即置 MODEL_SRC;:707-723 只 cp,不比 lock 的 sha256、不检查必需模型是否齐,cp 完只打 info 无 warn;:724 的下载兜底是同一链的
elif,MODEL_SRC 非空则永不执行。本 PR 打破了一个不变量:base 判据下该目录由 git 保证恒为 5 件套,现在由 fetch_models.py 填充,而后者部分失败时是「成功的已落地 + exit 1」 —— build.sh:326 一次失败的构建就足以造出部分填充的源目录。结果:cp 1 个 → 「新增 1 个」→ 不下载 → exit 0 零 warn,用户得到「安装成功」但首次 perceive 就 MODELS_MISSING 的系统。 - 建议修法: :724 的
elif拆成独立if,条件从「一个 onnx 都没有」改成fetch_models.py --check --dest "$MILOCO_HOME/models"非 0;或 cp 后无条件再 --check 一次。注意光拆 elif→if 不够(cp 完目录里已有 onnx,旧判据仍为假)。
🔵 次要
5. _stream 不校验 written == expected_size:干净 FIN 截断被误报成「sha256 不符」并使续传失效
- 问题: 读循环
if not chunk: break收尾,返回前不比较 written 与 total(只喂进度条);http.client 对「Content-Length 未满就 EOF」静默返回 b""(HTTPS 下suppress_ragged_eofs=True同样静默)。跨境链路 det_4C 每次到 ~30MB 被干净关闭 → sha256 不符 → :188 删掉那 30MB → 下一次不带 Range 从 0 重来(服务端日志实测:截断场景 3 次请求全 Range=None,超时场景才带 Range=262144 并续传成功)。4 源 × 3 次累计下行 ~360MB、进度永远为 0,诊断说的是「sha256 不符」,把方向指向「镜像被投毒」。docstring 自称「78MB 跨境链路值得」的续传在这条最常见失败类型上没兑现。 - 建议修法: 不要用 lock 的
expected_size比 —— 陈旧 lock 场景下 written 会大于 expected_size,会打出反向误导并留超长 .part。应用响应的Content-Length/Content-Range校验并抛异常,以保住 .part 让下一轮真正带 Range。
6. actions/cache 把取舍①变成随缓存年龄而定的间歇现象,且零漂移探测
- 问题: cache key 只由
hashFiles(lock)决定、无 restore-keys,而_is_ready比的是「本地字节 vs 本地 lock」、就绪即完全不联网。资产被换而某分支 lock 未 refresh 时:命中缓存 → 旧字节比旧 lock → 通过 → 零请求 → 绿;缓存 7 天未用被淘汰或换到无缓存 scope → 下到新资产 → sha256 不符 → 红。同一 commit 今天绿下周红。body 写了「老 commit 会构建失败」,没提这个失败被缓存变成时间相关的间歇现象。而对账是免费的:gh api repos/…/releases/tags/models --jq '.assets[]|{name,size,digest}'一次调用返回 5 条 sha256(实测与 lock 逐条相符,数据一直没被用)。注意供应链完整性并未被绕过 —— 任何绿都只能是「本地字节 == 本 commit lock」。 - 建议修法: 加一条零下载的对账门禁(lint job 或 daily cron),比对 assets[].{name,size,digest} 与 lock 全等,不一致
::error::(顺带抓住第 7 条的缩表);若要根治取舍①,按 publish_models.sh:16-18 自己写的路换不可变 tag。
7. publish_models.sh 的 refresh_lock_from_dir 全量重写 lock["files"],无「旧有新无」守卫
- 问题: :73
lock["files"] = out,用「只放要换的那几个模型」的目录跑 upload 会静默删掉其余条目。blocking 链路不可达(必需模型掉出会被 required 子集断言打红;tokenizer 掉出会让tar ./*.json未展开而硬失败),残留危害是静默丢一个可选模型 + 运行时一条 warning。 - 建议修法: 加
missing = set(old) - names硬守卫;并补一条 lock ↔ resource_validator.MODELS 的双向等价断言({(s.name, s.optional)} == {(f["name"], not f["required"])}),把「lock 是唯一真源」变成可执行约束。
8. 文档与口径三处不一致
- 问题: ① models/README.md:34 把 bge 写成「语义检索,缺则降级为关键词」,实际是 suggestion 事件链去重、缺则降级为精确文本匹配,与同 PR 的 lock「事件去重句向量」三方矛盾;silero 那行「缺则不做 VAD 切分」也不准(实际回退能量门控)。② fetch_models.py:21 把
MILOCO_MODELS_BASE_URL写成「优先于 base_url + mirrors」,实际是独占替换(设了就无兜底,且允许 http:// 明文)。③ install-hermes.sh:752 写的顶层cfg["models"]是死键(真键是directories.models,extra="ignore"静默丢弃,全仓无消费者),:742 注释也是错的;键错误是既有,但本 PR 把它从 cp 分支提升成独立 if,扩到了 release 装机与下载全路径。 - 建议修法: 三处按代码实情订正;dedup_embedder.py:7 与两份 knowledge 文档里「包内 perception/models/ 作兜底」的表述已过时,一并改。
9. 两个边界与一处纵深防御
- 问题:
--only <可选> --required-only得到空选集 → 空循环 → 静默 exit 0;lock 条目缺required键时spec.get("required")fail-open 成可选;dest_dir / spec["name"]对 name 无路径消毒(--lock可指任意文件,Path("/a/b") / "../../evil"会逃出 dest,f"{url}/{name}"也未 quote)。信任边界目前是「lock 在仓库里」,可接受。 - 建议修法: 空选集报 usage 错误或至少 warn;
required缺键改 fail-closed;加一句if "/" in name or name.startswith(".")守卫。
10. sync-to-remote.sh --remote-build 会删掉远端已有的模型,而换源 env 传不过去
- 问题: COMMON_EXCLUDES 不含
*.onnx/models/,--remote-build走rsync -az --delete-after。base 时代模型是 tracked 必然带过去;本 PR 之后本地 models/ 合法地可能只有 README → --delete-after 把远端上一轮的 5 个模型删掉 → 远端 build.sh 的 --strict 必须自己拉 78MB,而远端常是网络最差那台;ssh 段只透传 4 个变量,MILOCO_MODELS_BASE_URL传不过去。 - 建议修法: COMMON_EXCLUDES 加
--exclude 'backend/miloco/src/miloco/perception/models/',并把MILOCO_MODELS_BASE_URL加进 ssh 透传;或在 dev-guide 的 remote-build 段写明先本地 fetch 一次。
11. 三处新增的重复付费/重入面
- 问题: install-hermes.sh:679 的
--post-install只挡 banner、step 4.7 正文照跑 → 这个轻量重跑模式现在可能触发 78MB 下载;models/ 变 gitignored 后git clean -xdf与新 worktree 都会丢模型、各自重下 78MB;.part用固定文件名无锁,self-hosted 共享 workspace 或 --post-install 与 build 并行会互相截断(失败是响的、无坏产物)。 - 建议修法:
--post-install下跳过下载只做 cp/校验;.part改f"{name}.{os.getpid()}.part";dev-guide 提一句 git clean 后需重新 fetch。
|
补一个和现有 comment 不完全重复的维护风险:Release 旧模型 asset 可能残留并被重新写回 lock。 已有第 7 条主要覆盖的是「用不完整本地目录 refresh-lock/upload 会把 lock 缩表」。这里是反方向的问题: 触发例子:
影响短期通常不是功能崩溃(运行时大多按固定文件名读,额外旧文件可能没人用),但会让 release 包变胖、磁盘/下载成本增加;如果某个模型是因质量/许可/安全/兼容原因下线,也可能继续被分发。更关键的是, 建议至少补一个 allowlist/漂移守卫:
|
ExWang 的 11 条 review 意见 + Zirconi 的 Release 资产漂移问题。 修的几个真 bug: - install-hermes 步骤 4.7 的兜底判据恒为假。旧判据「一个 onnx 都没有才 下载」挂在 cp 之后,而 cp 只保证「源目录里有的都过来了」、不保证齐。 模型不再进 git 后源目录改由 fetch_models.py 填充,它部分失败时的形态是 「成功的已落地 + exit 1」—— 一次失败的构建就能留下只有 1 个 .onnx 的源 目录,于是「新增 1 个 → 零 warn → 安装成功 → 首次 perceive 就 MODELS_MISSING」。改成独立 if,用 --check --strict 按 lock 逐个比字节。 用 strict 是因为可选模型缺了也该补,否则 bge 去重 / VAD 静默降级。 - config.json 写的是死键。顶层 cfg["models"] 全仓无消费者,真实键是 directories.models;MilocoSettings 的 model_config 为 extra="ignore", 多出来的顶层键连报错都没有。改写 directories.models 并保留同级子键。 - .part 文件并发截断:flock + pid 兜底,Range 续传在常见情况下保留。 - CI 缓存可能固化不完整状态。actions/cache 的 post-step 无论 job 成败都 保存,拆成 restore → 下载 → save(if: cache-hit != 'true'),另加一道 零网络的 --check --strict 门禁。 - sync-to-remote 的 --remote-build 走 rsync --delete-after,本地模型目录 合法地可能只剩 README,不排除的话会把远端上一轮下好的删掉、逼远端重拉 ~78MB —— 而远端往往正是网络最差那台。顺带透传 MILOCO_MODELS_BASE_URL。 - 模型目录三处定义不一致(缓存 path / --dest / build.sh 的 pack_models), 在 release.yml 的 build job 上收敛为 job 级 env.MODELS_DIR。 新增 publish_models.sh verify(Zirconi):零下载对账,一次 API 拿资产清单 与 lock 逐项比 name/size/sha256。它把一类**间歇性**失败变成确定的失败点 —— models 是固定且可变的 tag,资产被换而某分支 lock 没跟着刷时,构建能不能过 取决于 actions/cache 有没有命中:命中就拿旧字节比旧 lock(绿),缓存一被 逐出就下到新资产、sha256 不符(红)。同一个 commit 今天绿下周红,看日志 完全看不出为什么。同时给 refresh-lock 加文件集漂移门禁(少了会静默缩表、 多了会以 required=false 默默收编),upload 后自动对账一次。 文档三处 claim 逐个对着代码核过:bge 缺失 → 退回精确文本匹配、VAD 缺失 → 退回纯能量 gate、不存在「包内 perception/models/ 兜底」这回事。更正 MILOCO_MODELS_BASE_URL 的语义为独占替换而非「排在前面」。 顺带修 sync-to-remote.sh 的 usage():BSD sed(macOS)不认 BRE 的 \?, '#' 会原样打出来。scripts/build.sh:50 有同样的 bug,不在本 PR 范围,未动。 后端全量 3 failed / 2985 passed,clean tree 基线 3 failed / 2979 passed (同样的 macOS node_monitor smaps 预存失败),+6 新增通过,无回归。
- 修正上一轮改错的文档:包内 perception/models/ 兜底确实存在 (tracking_service.py / detector.py / api.py 三处从 __file__ 上溯), 两处文档重写为「配了 models_dir 用它,没配才回退包内」的两段式描述 - publish_models.sh: 文件集比对前移到 gh release upload 之前 (check_fileset_against_lock),避免资产已推上公开 Release 才报错、 lock 还是旧的、所有人 PR 被对账门禁判红 - 新增 backend/miloco/tests/test_publish_models.py,15 个用例覆盖 verify 的全等/缺漏/多余/sha256 不符/digest 降级,与 upload 漂移中止 - build.sh: 抽 is_full_build(),子集构建跳过 pack_models —— 否则 --packages miloco-cli 也会被 fetch_models.py --strict 拖去下 ~78MB, 且产出的 tar 在子集构建下无人消费
修回上一轮引入的回归 + 两处口径/门禁收尾: - build.sh: pack_models 的门禁从 is_full_build 放回 should_build "miloco"。 模型 tar 的消费方不止平台归档,还有 install.py 的 dev 通道(src_dir 直接 就是仓库 dist/),罩上全量门禁会让「--packages <子集> 再 install.sh --dev」 在 wheel 全装完之后才 fail。pack_platform_bundles 恢复无条件调用(内部 已有 is_full_build)。顺手补上 --help 里漏掉的 hermes,并把 download.models_missing 的中英文案指向 dist/ 而非包内 models 目录 - perception/models/README.md: 与两份知识库同口径,改成两段式解析描述, 不再说"运行时不是本目录" - fetch_models.py: 所有源试完后不再无条件删 .part —— 稳定名留着供下一次 调用带 Range 续传(原实现下跨调用续传只在 Ctrl-C 时有效);pid 名与 0 字节的壳仍然清掉。新增端到端续传测试锁住该行为 - ci.yml: 对账门禁按"能不能修"分流 —— push 与改了 lock 的 PR 强制, 其余 PR 降级为告警(models 是可变 tag,换代窗口内无关 PR 会被判红, 而两条修法都要仓库 write 权限)。verify 失败输出补归属说明
|
/review |
[PR #492]: chore(models): move perception ONNX models out of git to a pinned Release作者: idootop 修改方案要解决的问题 感知链路的 5 个 ONNX 模型(合计 ~78MB)一直存在 git 里:每个人 clone 都得背上,每换一次模型 git 历史里就再压一份,而且这堆二进制还会被打进 wheel。本 PR 把字节搬到 GitHub Release 的固定 tag 整体方案(5 条正交主线) 主线 1 — 仓库里只留一份"谁、多大、什么摘要、必不必需"的清单
三份"同一批文件"的事实源及其锁定方式:
主线 2 — 一个只依赖标准库的下载器
源选择的分支:
单个文件的下载路径: 退出码是给上游脚本读的契约:
主线 3 — 发布侧脚本:上传、对账、重算清单
两个值得单说的实现选择:
主线 4 — CI / 构建 / 同步链路接线
缓存这一步为什么要拆成 restore + save 两个 action 而不是用一体的 主线 5 — 安装侧接线(hermes 插件 + 安装器)
关键设计原则
测试覆盖
上轮 ci-bot review 对账
独立复核除对账 ci-bot 旧条目外,本轮另外跑了这些检查,均未发现问题:
静态审查限制(需明示):本轮 review 全部结论来自源码层交叉核对——本 runner 上没有 结论LGTM — 上一轮的 🔵 与人类 reviewer 提的残留资产问题都已闭环,本轮独立扫描(反向引用、PR 描述逐条核对、跨层一致性、脚本可移植性)未发现新问题;唯一保留意见是新增测试未在本地执行,以 CI 结果为准。 由 review-pr skill v1.6 生成 |
usage() 原来是 `sed -n '2,26p'`,本 PR 给抬头补 ONNX 说明时正是手工把 24 bump 成 26 才没截断。下次再补一条而忘了同步行号,`-h` 会被静默截短,没有测试或 CI 会拦。 改用与 publish_models.sh 同一种提取方式:跳过 shebang,打到第一个非 # 行为止。 帮助输出与改动前逐字节一致;在抬头追加一行后实测新增行会被打出(旧实现会吞掉)。
文档里的 directories.models_dir 与实际 schema 对不上(🟡):models 才是字段,
models_dir 是 @computed_field 派生只读属性,DirectorySettings 无 model_config、
pydantic v2 默认 extra="ignore",照 dev-guide 写进 config.json 会被静默丢弃、
路径回落 $MILOCO_HOME/models、最终以 MODELS_MISSING 收场且无任何警告。实测
model_validate({"models_dir": ...}) 后 models 仍为 ""。dev-guide / sdk-onnxruntime /
dedup_embedder docstring 三处改成真实键名并点明派生属性只读,与同 PR 内
perception/models/README.md、install-hermes.sh 写入的键统一。
另三条建议:
- local-ci.sh 就绪校验补 --strict,与 ci.yml 门禁同强度;原来可选模型缺失退 0,
本地静默跑降级路径却自认已对齐 CI。
- ci.yml 下载步注释改写:拆两步买到的是失败归因可分与残缺不进缓存,最终策略
仍是全严格,原注释的"镜像抖动不判红"会被理解成可选模型下不动不影响 CI。
- sync-to-remote.sh --packages 帮助补上 web,hermes(原样透传、本脚本不校验),
并注明非全部 6 个时按子集构建、不产平台一体归档。
_open_part 靠对稳定名 .part 加 flock 来保证「同一 dest 下只有一个进程在写同 一个文件」。但 flock 锁的是 inode,不是路径:一旦 unlink 掉目录项,锁就挂在 一个没有名字的 inode 上,别的进程在同一路径新建 inode 后能成功抢到锁 —— 两边 于是并发写同一个 .part,交错出来的字节永远校验不过,表现成「重跑多少次都是 sha256 不符」,把诊断指向「镜像被投毒」,而网络和镜像其实都是好的。 两条 unlink 都在锁的生命周期内: - _stream 里「已有内容 ≥ 目标大小」那条尤其要紧 —— 目录里留着一份换代后的超长 .part 就会命中,不需要任何 sha 不符,整轮调用从第一次 _stream 起就无锁。 - _fetch_one 里 sha 不符后紧跟 2^n 秒退避 sleep,正好把窗口拉开到秒级。 改用 os.truncate(path, 0):inode 保住(锁跟着保住),内容清空,对下游完全等价 —— _stream 下一轮 stat 得 0,不带 Range、以 "wb" 整段重写。收尾块里第三处 unlink 保留:它是 finally 释放锁前对该路径的最后一次触碰,且清掉 0 字节空壳有用。 同时: - ci.yml:列 PR 改动文件失败时降级为告警而非硬失败。这一步自己没有 continue-on-error,默认 shell 带 -e,二级速率限制会让一个只改前端的 PR 整个 lint 变红,报的还是「判定对账门禁强度」失败 —— 贡献者看不懂也修不了。 - local-ci.sh:--strict 的提示文案改成覆盖它实际命中的两种情况。原文只提 requires_models,「只缺可选模型」时这条会打出来而一条 skip 都没有,读的人会 把它归档成噪音,下次必需模型真缺时也就拦不住人了。
publish_models.sh: cmd_verify 拿资产清单失败时用 return 而非 die。die 是 exit, 在函数里退的是整个 shell,cmd_upload 那句 `if ! cmd_verify` 接不住。而那次对账是 故意设计成非致命的——跑到那儿资产已经推上 Release、lock 也已落盘,一次 API 抖动 不该把「别忘了提交 lock」那行一起吞掉:维护者看到 FATAL + 退出码 1 会读成「上传 失败」,于是要么重跑一遍 upload,要么没提交刷新后的 lock,把 CI 的对账门禁留给 下一个人踩。抬头注释第 44-46 行已为 check_fileset_against_lock 点过同一类坑。 实测:修复前 FATAL/exit 1 且收尾提示不打印;修复后走告警分支、提示照打、exit 0。 直接跑 verify 的退出码不变(实测仍为 1),CI 门禁行为完全一致。 build.sh: 删掉「--help 里印的那行包列表恰好漏了 hermes」这句——本 PR 第一个 hunk 正是把它补全的,补全后与 ALL_PACKAGES 逐字符相同,照抄现在会判**全量**,跟注释 说的正好相反。换成不会过期、且点出真正坑点的说法:is_full_build 是字面全等, 少写一个包或只是换个顺序都算子集。 install-hermes.sh: 4.7 的短路判据从「目标目录有没有 .onnx」改成「按 lock 齐不齐」, 与最后那道门禁复用同一个 models_ready。按「有没有」的话,目标目录里躺着 1 个 silero_vad.onnx(上次下到一半断网 / 手工拷过一个)就会跳过整段 cp,而门禁照样判 不齐,于是从 Release 联网下 ~75MB——那 4 个文件此刻就在旁边的 checkout 里,一次 cp 就够;断网时更糟:下载失败只 warn 不中断,安装报成功,首次 perceive 直接 models_missing,而完整的本地副本从头到尾都在一个目录之外。Release 装机场景不受 影响:tarball 解全了就判齐、照样短路,不多跑那次 import miloco。
cmd_upload 收尾那句 `if ! cmd_verify` 是**故意**非致命的:跑到那儿资产已经 推上 Release、lock 也已落盘,一次抖动不该把「别忘了提交 lock」一起吞掉。 但 cmd_verify 开头的 need_gh 里是 die(= exit),在函数里退的是整个 shell, 调用方接不住 —— 和上一轮修掉的 `gh api` 失败路径是同一个类。 这处更隐蔽:gh auth status 不是纯本地检查,它要发一次请求验 token。从 cmd_upload 开头那次 need_gh 到这里隔着 78MiB 上传 + 78MiB 重算 hash, 通常好几分钟,token 过期或网络抖一下就够了。 前置检查改由调用方做(cmd_upload 已查过,直接跑 verify 由 case 分派处查), verify 子命令的门禁强度不变。搬出去之后 cmd_verify 函数体内再无任何 exit。 两条回归测试钉住的是「函数体内不许有 exit」这个性质,而不是某一处具体的 die。
本 PR 把 download.models_missing 从「dev 请确认 perception/models/ 下 .onnx 齐全」改成了「dev 请确认 dist/ 下有 miloco-models-*.tar.gz」,但同一条失败路径 上方两行的注释还停在旧语义。 而旧语义对这段代码本来就不成立:_step_download 调的是 _extract_models(_get_src_dir(), ...),dev 通道下 _get_src_dir() 返回仓库 dist/,函数体只 glob miloco-models-*.tar.gz,从头到尾不看包内模型目录。 只影响维护期:后来的人照注释去 perception/models/ 找 .onnx,会发现那个目录 在本 PR 之后按设计就是空的(只剩 README),排查得绕一圈才回到 dist/。 顺手补一句说明那个目录现在的定位,免得下次又有人往那儿找。
_stream 开头那道脏文件守卫只挡 offset >= lock.size 一侧,挡不住 [实际资产长度, lock.size) 这个窗口:lock 比线上资产长(换代后没 refresh)、 目录里又留着一份落在窗口里的 .part 时,Range 起点会越过资产末尾,服务端按 RFC 回 416。而 HTTPError 是 URLError 子类,正好落进"故意不删 part"那个 except —— 偏移量一个字节都没变,3 次重试 × 4 个源全部原样复现同一个 416, 收尾还打"已保留 X 待续传",下一次调用从同一个偏移量再演一遍。永不自愈, 而日志把人指向"网络/镜像有问题",和唯一的解法(丢掉这截字节)正好反向。 416 是第三类失败:"续传起点本身非法"——起因是我们自己攒下的状态,不是 外部原因。处置与 sha256 不符那条同构(都是"本地这截不能要了"):截断归零, 同一个源的下一次重试就不带 Range 从 0 重下。 同类扫过:lock 缺 size 键时守卫根本不触发,同样 416 卡死,本补丁一并覆盖; 服务端不认 Range 回 200 的路径已自愈(offset 归零 + "wb" 整段重写); 404/403/429/5xx 不是自攒状态造成的,重试/换源本就是对的处置。 回归测试起了一台按 RFC 回 416 的本机服务,如实搭出触发条件 (lock 2327 / 资产 2000 / .part 2100),撤掉补丁即红。
三条 review 🔵,逐条核实后都成立:
1. publish_models.sh:31 把 $LOCK 插进 Python 源码串(open('$LOCK')),是全文件
唯一一处——另外三处调 python3 都是当 argv 传的。clone 到含单引号的路径下
(/home/o'brien/miloco)展开出来就是语法错误的 Python,而这行在 case 分派
之前求值,用户拿到一段 SyntaxError traceback,连 --help 都打不出来。实测
复现并确认修复后两种路径都正常。
2. perception/models/README.md 那段「配了 directories.models(默认
$MILOCO_HOME/models)就用它…没配时才回退到本目录」把两级判定焊在一个括号里,
按字面读会得出错的结论:以为不配就用源码树里这个目录。实际上「没配」拿到的是
$MILOCO_HOME/models,真正落到本目录的条件是构造感知引擎时完全不传
perception_model_dir。而 wheel 形态下本目录必然为空——正是同段末尾警告的场景。
同类扫过:这段口径被本 PR 复制到了三处,knowledge/06-dev-guide/dev-guide.md
与 knowledge/05-external-deps/sdk-onnxruntime.md 同样混淆,一并改成两段各判
各的写法。
3. test_upload_proceeds_when_fileset_matches_lock 的注释说「让它对上即可」,
代码却是 set_assets([])。改注释而不是改代码:让代码兑现注释等于重复覆盖
test_verify_passes_when_assets_match_lock 已经钉住的东西。新注释顺带点明
这两条路各由哪个用例覆盖。
① _is_ready 只认 sha256,去掉 size 预判 下载落地时校验只看 sha,--check 却先看 size —— lock 里 size 与 sha256 描述的不是 同一份字节时(手改 lock 敲错一位、换模型只更新了 sha),CI 上紧挨着的两步会一绿一红: fetch_models.py --dest X 打「校验通过」退 0,--check --strict --dest X 立刻判「缺失或 校验不通过」退 1,而它给的修法正是刚跑成功的上一步,重跑多少次都一样。 去掉不多花:happy path 上 size 本来就对,照样要算这一次 hash。lock 的 size 仍供 _stream 的脏文件守卫和进度显示用,它自身对不对由 publish_models.sh verify 拿线上 资产的真实大小去对 —— 那才是该报「size 不符」的地方。 新增 test_download_and_check_agree_on_a_self_contradictory_lock 钉死(把 size 预判 塞回去即变红,已验证)。 ② install-hermes.sh:cp 之后追加一趟 file:// 下载 那轮 cp 的跳过判据是文件名,而换模型走的正是同名覆盖(gh release upload --clobber, sha 变了名字没变),于是老用户升级时旧模型被原样留下:联网时门禁判不齐 → 白下几十 MB,正确的字节就在旁边 checkout 里;断网时下载失败只 warn 不中断,安装报成功,而 resource_validator 只查文件在不在,旧模型被静默加载。 拿 checkout 当 file:// 源再跑一趟下载器,把「要不要覆盖」交给 sha256 判:已经对的 一个字节不碰,只有真过期的才就地覆盖。lock 的 5 个名字与 cp 循环同一批(含 bge tokenizer 的 .json)。源 URL 走 as_uri() + argv,避免带空格/# 的 checkout 路径拼出 解析错的 URL。整段静默失败,缺什么由下面的门禁和联网那趟去报。 未加 shell 级用例:仓库没有 install-hermes.sh 的测试骨架,为一处三行改动搭一套不 划算;真正的仲裁者是 fetch_models.py 的 sha 覆盖逻辑,已由 27 个用例覆盖。
① 本地源不再干等(scripts/fetch_models.py) 上一版新加的「拿旁边 checkout 当 file:// 源同步一趟」会为源目录里**没有**的模型 白跑退避:下载器不知道这是本地源,对「源上没这个文件」走的是和网络故障一样的路径, 每源 3 次、中间 sleep 1s+2s。env 是独占替换只有一个源,于是每个缺失文件固定 3 秒。 而 fork 的 checkout 只带一部分模型正是常态(那段注释自己写明了),加上整段静默, 实测 5 缺 4 时安装器在两条 info 之间无声卡住 12.2s,对结果毫无贡献——那几个本来 就该由后面的门禁和联网那趟去补。 改在下载器而不是调用点加 --only:判据「等一会儿能不能改变结果」只跟 scheme 有关, 放在这里一处惠及所有本地源调用方(含文档里 MILOCO_MODELS_BASE_URL=file:// 的离线 场景),也不必在 shell 里绕一圈读 lock。重试本身照留(本地源也可能撞 EIO),只是 不再 sleep。实测 12.2s → 0.1s。 新增 test_local_source_failure_retries_without_backing_off:进程内记 sleep 调用, 断言 file:// 不退避、重试次数不变、http 源仍是 [1, 2](撤掉条件即变红,已验证)。 用进程内而非墙钟阈值的理由与 test_discarding_part_keeps_inode… 同:跨进程只能赌 CI 负载。 ② 三处复述跟上 _is_ready 的判据变更 上一版把 size 预判从就绪判据里摘掉,但「判据是什么」在仓库里被复述了三遍: - plugins/hermes/install-hermes.sh(挂在 models_ready 头上,插件链路判齐不齐的唯一入口) - knowledge/06-dev-guide/dev-guide.md(照它估成本会以为常见情况只花一次 stat) - PR 描述的「怎么拿模型」表 三处都改成只说 sha256,并把「size 由谁仲裁」指向 publish_models.sh verify,免得 读者以为那个字段没人管了。dev-guide 那处顺带写明「零流量但不是零成本」。 全仓扫过其余 --check 描述(models/README.md、ci.yml、local-ci.sh)都只说 「不联网 / 只校验」,publish_models.sh 那两处说的是 lock 记哪些字段,均无需改。
MILOCO_MODELS_BASE_URL 给裸路径(挂载目录)时,值会原样拼进 URL, urllib.request.Request 抛的是 ValueError —— 不在下载循环那个 (URLError, OSError, TimeoutError) 里,一路穿出 main:traceback、 退 1(文档定义为"必需模型缺失")而非文件头承诺的 2,剩下的文件不再 尝试,"可用 MILOCO_MODELS_BASE_URL 换源"那句提示永远打不出来 —— 插件安装器接住非 0 后打的是"下载失败(网络?)",而用户明明是刻意离线的。 而两个文档入口都没把"要带 scheme"讲硬(sync-to-remote.sh 说的是 "内网源",README 与 dev-guide 只给了 https 的例子),裸路径正是最自然 的写法。故 env 侧兜底成 file://(scheme 长度为 1 按裸路径处理, Windows 盘符 C:\models 不会误伤),非 http/https/file 的 scheme 退 2。 同源问题 lock 侧也有一份(base_url / mirrors 写成裸路径,同样的 traceback)。lock 侧刻意**不**兜底:它是提交进仓库、由 publish_models.sh refresh_lock 生成的产物,写成裸路径就是坏了,而"坏 lock 退 2"是本脚本 已有的契约。跟着兜底会把一眼能定位的配置错误变成"从一个不存在的本地 目录下载失败",把人指向换源,而真正坏的是 lock 自己。 测试 28 → 31,三条均已验证 revert 后失败。
fetch_models._required 缺键 fail-closed 判必需,publish_models.sh refresh_lock_from_dir 缺键判可选 —— 同一个字段两侧默认值相反。 「lock 条目由人手写/手补」是这套流程文档化的正常路径(refresh 里那句 "新增项记得手工把 required / desc 与 resource_validator.py 对齐")。 一个同名条目只是漏了 required 键时:漏写的那阵子一切是绿的(下载器一直 当它必需,没有任何信号),文件集护栏只比名字集合、看不见"同名但少一个 键"这种漂移,于是下一次 upload / refresh-lock 把它静默写成 required=false。 实测:把 det_4C.onnx 的 required 键删掉后跑 refresh-lock,rc=0、无任何 输出,lock 里该条变成 required=false 并从第 1 行掉到第 4 行(排序按 required 走,diff 看着像纯噪声)。 翻面的代价落在唯一一条不带 --strict 的调用上 —— install-hermes.sh:787 那趟联网补齐,退出码从 1 变成 0(实测),三条 warn 一条不打、安装报成功,用户第一次 perceive 才拿到 models_missing。 build.sh 与两个 workflow 都带 --strict,不受影响。同时 lock 说可选、 resource_validator 里仍硬编码必需,恰好是 :106 点名要人工对齐的那个字段 自己分了家。 改成:同名旧条目缺键 fail-closed 判必需(= 保留下载器眼中的语义,符合 函数注释承诺的"保留同名文件的 required");真正新增的文件仍默认 false, 那条路径已由护栏显式放行并要求人工对齐。 同类扫描:lock 里 name / sha256 两侧都是硬键(缺了 KeyError,吵), size 缺了只关掉截断检查、无语义翻面,desc 空即空 —— required 是这一类 里唯一一个两侧默认值相反的。 refresh-lock 此前零覆盖,补两条:缺键必须保留为必需(revert 补丁即红)、 以及反方向的"真正新增的文件仍默认可选"(防止为修这条把所有条目翻成必需)。
顶层那句 `TAG="$(python3 -c ... "$LOCK")"` 在 die 定义之前、也在 case 分派之前求值,于是 lock 不存在 / JSON 坏掉时**任何**子命令都先吐一段 Python traceback 再退 1 —— 连 --help 都一个字打不出来,而人在这个时候 恰恰最需要看一眼用法。典型触发是合并冲突标记:两个分支各自 refresh 过 lock,合出来的工作区里这个文件必然是非法 JSON。CI lint job 里 `publish_models.sh verify` 红出来也是 traceback,读的人第一反应是 "脚本崩了"而不是"清单坏了"。同一份坏 lock 交给 fetch_models.py 则是 一行中文 + 非 0。 改成 require_lock(),延到 case 分派之后调用。三个子命令一律先过这道 校验,**不**按"用不用得上 TAG"分叉:lock 一共 4 处读取点,按需分叉的话 refresh-lock <dir> 恰好被豁免(它确实不需要 tag),而 refresh_lock_from_dir 内部照样 json.loads 同一份文件 —— 实测那种改法下 traceback 原样还在,而"合并完先跑一次 refresh-lock"正是最容易撞上冲突 标记的那条路。 退出码不跟着对齐到 fetch_models 的 2:本脚本的 die 一律退 1,唯一的 自动消费方是 ci.yml 那句 `run:`,只看零/非零,为此另立码段没有收益。 require_lock 排在 need_gh 之前:纯本地、确定性的那道先说话,别让一个 坏 lock 先报成"gh 没登录"(gh auth status 还要发一次请求)。 补 5 条回归:3 个子命令 × 冲突标记、release_tag 空串、--help 仍可用。 完全 revert 则 5 条全红;只按"按需分叉"修则 refresh-lock 那条独红。
publish_models.sh 里 4 处 `:行号` 交叉引用全部指错,两种成因各占一半,
都不是"写的时候不小心"能治的:
:85 / :106 —— 引入它们的那个 commit 上就已经错了。按缓冲区里的行号
写下引用,同一个补丁后面又插了二十来行,提交时它就飘了。
:136 ×2 —— 写的时候是对的(5abe53f…982cbbd 上 L136 确实是 need_gh),
之后两个 commit 把它顶下去,成了 `out.append({`。
所以换成不会飘的符号引用,而不是把行号改对:
:85 → 「那段 Python 的头一句就是」(refresh_lock_from_dir 里的 json.loads)
:106 → 「护栏那句『新增项记得手工把 required / desc 与 resource_validator.py 对齐』」
:136 → 「cmd_upload 开头那句 need_gh」
全文件扫过一遍,剩下的 `:NN` 只有 `[:16]` / `[:12]` 这类 Python 切片。
不为此加 lint:符号引用本身不会漂,而按 `:NN` grep 会误伤合法的跨文件引用
(`ci.yml:187` 之类),为一个已经按构造消掉的问题铺一层误报面不划算。
troubleshooting.md 的 `models_missing` 行只写了"重跑 install.sh",模型移出
git 之后源码开发者照做要绕一大圈(装安装包只为补两个 onnx)。补上
`fetch_models.py --dest`,与 dev-guide.md 里那条同形;两条路都留着。
表格是 prettier 重排的(该文件在 knowledge/**/*.md 的 CI glob 内),除这行
外全是列宽空白。
local-ci.sh 那段就绪检查显式传了 --dest(包内目录),印给人照抄的补齐命令 却是不带 --dest 的裸命令 —— 两套目录。裸命令的目标目录会回退到 MILOCO_MODELS_DEST,而把模型放仓库外共享给多个 worktree 的人常在 profile 里长期 export 它。实测那种环境下的完整闭环: $ MILOCO_MODELS_DEST=/tmp/xxx python3 scripts/fetch_models.py --check --strict 缺少模型:det_4C.onnx, ... 补齐:python3 scripts/fetch_models.py --dest /tmp/xxx ← 下到这儿 78MB 落进 $MILOCO_MODELS_DEST,再跑一次 local-ci.sh,告警一字不差地又来一遍: 它校验的仍是包内目录,requires_models 那批也照旧整批 skip(判据是 test_deep_sort_v12.py 里 __file__ 推出来的包内目录,环境变量够不着)。而人 手里唯一的线索就是这条命令 —— 第 57 行注释警告的正是这个陷阱,第 69 行自己 踩了进去。 改法不是"把 --dest 补上"就完事:相对路径提成 models_rel 写一次、绝对路径由它 派生,校验与提示因此不可能再分家。同一个思路上一轮已经用过(行号引用换符号 引用)—— 能按构造消掉的漂移,不必再拿测试去追。不加 grep 式断言的另一层原因 是它只能证明"字符串里有 --dest",证不出"和校验的是同一个目录"。 印相对路径而非绝对路径:命令前半截 `scripts/fetch_models.py` 本来就是仓库根 相对的,绝对 --dest 也救不了从子目录粘贴(前半截照样找不到文件),两截同口径 才是一条能整体粘贴的命令;且这个常量里没有空格 / 引号,不必再考虑转义。 同一个坑的文档侧:dev-guide 里穷举"哪些入口显式传 --dest"的那句漏了本 PR 新加 的 local-ci.sh,读的人会以为环境变量在这里生效,于是把反复报模型缺失当脚本 bug 去查。补进清单并点明它的特殊之处(校验的是包内目录,往别处下不会让告警消失)。 全仓扫过同类:fetch_models.py:481 与 install-hermes.sh:782 两处补齐提示本来就 按各自实际 dest 插值,local-ci.sh 是唯一一处。
两处都是「失败时退错码/静默降级」,各自比 review 点到的范围要大一圈。
fetch_models.py:_check_name 升级成 _check_spec,在 main 读 lock 的 try 里
一次校验完下载器真正依赖的键。原先只有 name 被拦,sha256 缺失会在第一次校验
就 KeyError,穿到下载中途才炸 —— 那时退的是 1,而 1 在本脚本契约里是「必需模型
缺失」,install-hermes.sh 的四分支门禁照这个含义提示「稍后重试」,用户重试多少
次都没用,坏的是本地这份 lock。
顺手补了 review 没提的两条同类:
- size 类型:_human() 与续传越界判断都拿它做算术,字符串会 TypeError。校验流程
本身不读 size,所以没有这道拦截时 CI 的 `--check --strict` 会对着坏 lock 亮绿,
等下载才炸(同样退 1,同样被读成「重试就好」)。
- sha256 大小写:比对的是 hexdigest() 的小写输出 + ==,lock 里写成大写(Windows
的 certutil -hashfile 就是大写输出)会让每个文件都判「校验不通过」,表现成永远
下不完的重下循环,而每一步单看都正常。这里统一归一到小写。
build.sh --help:写死的 `sed -n '5,15p'` 换成打到抬头注释块结束,与
publish_models.sh / sync-to-remote.sh 同一写法 —— 往选项列表插一行就会把结尾
「退出码:」那行无声截掉,本 PR 自己就动过这段抬头。同时修掉一个 macOS 上一直
存在的显示 bug:原来 `sed 's/^# \?//'` 的 \? 是 GNU 扩展,BSD sed 当字面问号,
于是 macOS 上每行帮助都还挂着 "# "。awk 用 ERE,两边一致。
验证:真实 models.lock.json 5 条全部通过 _check_spec 且无一条需要大小写归一;
publish_models.sh 生成侧是 st_size(int) + hexdigest(小写),与新校验同口径。
测试 +8(7 参数化坏键 + 大写摘要照常认),63 passed。
两条都在错误路径上,第一条比 review 点到的范围大一倍。
fetch_models.py:`.get(key, default)` 的默认值只在**键不存在**时生效,键在、值是
null 时原样返回 None,于是每个可选键都从为它设计的判据下面绕过去。review 点的是
size,扫了一遍两个可选键都中,且后果完全不同:
- size: null → 下游 spec.get("size", 0) 拿到 None → _human() 比大小 →
TypeError → traceback + 退 1。--quiet 救不了,f-string 的参数
在调用 _log 之前就求值了。
- required: null → _required() 里 bool(None) 是 False,必需模型被静默降级成可选。
build 的 --strict、CI 门禁、install-hermes 的就绪判据全照
required 判,于是少一个必需模型的包能一路退 0 发出去 —— 比炸掉
更糟,因为没有任何人收到信号。这正好是 baec217 立起来的
fail-closed 契约的反面:漏写键它兜得住,写成 null 反而兜不住。
所以修的不是 size 这一处,而是在 _check_spec 里把"键在、值是 null"统一归一成
"键不存在",两个键一条规则。归一后 required 落到 True(fail-closed,与漏写键同结论),
size 落到"不显示百分比"(本就是合法写法)。required: false 仍然是真可选,没有被
一起收紧。
install-hermes.sh:790:下载失败分支印的修法命令缺解释器,而本文件在 git 里是
100644、没有可执行位,用户原样粘贴会 Permission denied(退 126)—— 这个新错误跟
下载失败毫无关系,只会把人往"权限 / 文件损坏"方向带偏,而用户此刻手上只有这一行线索。
全仓扫了一遍所有 fetch_models.py 的调用点与印出来的命令,这是唯一的漏网。
验证:两条新用例各自 revert 掉归一化即变红;size/required 的 null 与缺键、
required:false 五种组合退出码逐一核对;真实 models.lock.json 不受影响。65 passed,
ruff / bash -n / shellcheck 干净。
`${FETCH_MODELS:-scripts/fetch_models.py}` 的兜底值只在变量为空时取用,而变量为空的
充要条件就是"本脚本旁边没有 checkout"(见 FETCH_MODELS 那两行探测)—— 也就是相对
路径 scripts/fetch_models.py 必然解析不到的那种处境。兜底值只在它必然错的时候才出场。
而 --post-install 的唯一调用方 install.py 正是从 tarball 解出来的目录调的
(build.sh::build_hermes 只把 install-hermes.sh 和插件目录打进去,没有 scripts/),
且 subprocess.run 没给 cwd,相对路径落到用户当初敲 install.sh 的那个目录上。用户照抄
得到的是 "can't open file .../scripts/fetch_models.py",跟"感知模型不齐"毫无关系,
只会把人往"文件损坏 / 路径写错"带偏 —— 正是上一个 commit 在 790 那段注释里刚论证过
要避免的事,同一份文件里犯了两次。
改成按 FETCH_MODELS 是否可用分叉,两条都给能真正执行的动作。与 review 建议的差别是
没提 scripts/models.lock.json:走到这个分支说明手边没有 checkout,那个文件同样不在,
再指过去只是把同一个错误往下挪一层。换成"重跑 install.sh"——安装包自带
miloco-models-*.tar.gz,install.py::_extract_models 解到 $MILOCO_HOME/models/
(models_dest = miloco_home / "models",已核对),而用户既然在跑 --post-install,
这条路他本来就走过。
没加测试:分叉判据用的就是门禁下载那条分支的同一个变量,"能不能跑"与"告诉你跑什么"
不可能再分家,跟 local-ci.sh 那次把路径收敛成单一字面量是同一个道理。仓库也没有
install-hermes.sh 的测试宿主。
全仓扫了其余印给用户的 `:-` 兜底,只有 sync-to-remote.sh 的 ${INSTALL_LIST:-none},
那是展示用占位符、不是让人粘贴的命令,不同类。
ExWang
left a comment
There was a problem hiding this comment.
Review:感知模型移出 git、改按 sha256 钉在 Release —— 结论:差一步
方向对、契约设计扎实的一次"模型出库"。清单作单一事实源,下载器纯标准库(安装阶段无第三方依赖即可拉模型),原子落地 / 续传 / 416 自愈 / 环境变量独占换源 / 路径穿越守卫 / 退出码契约都核过,测试也真绑在行为上而不是形状上。上一轮提的十一条里七条已经真闭环,改法多数比建议的更强。差的一步集中在一处:安装侧那条"补齐模型"的链路上,进分支与出分支用了两套判据,于是上一轮那个"模型不齐却报安装成功、零告警"的失效形态在当前 head 仍可复现,且新增了一句在该分支下不成立的成功提示。修法是一个词加一行文案。
角度一:问题是否存在 —— 存在
二进制长期在 git 里,未来每换一次模型历史里再叠一份,且会被打进 wheel。移到 Release 按 sha256 钉版是常规解法。描述里对收益的边界也标得诚实:历史里那份没有重写,所以仓库不会立刻变小,这条只对未来的 commit 生效。
角度二:修改正确性 —— 主体成立,安装侧有一处必须先修
下载器一侧逐条核过:就绪判定只认摘要、字节数不参与;丢弃半成品用截断而非删除以保住锁所在的 inode;截断检测比的是响应的 Content-Length 而不是清单里的 size;换源变量是独占替换、不回落公开镜像;空选集与坏清单一律收敛成用法错。构建与 CI 一侧:校验的目录、逐名断言的目录、打包的目录现在是同一个,打包也从整目录 glob 改成按清单里的名字逐个打,残留文件进不了发行包。清单缩表的守卫落在重算函数体内、双向报,所以"从本地目录重算"和"从 Release 拉全量重算"两条路径都拦得住。
🔴 必须先修(gating)
1. 补齐模型的下载调用漏传严格开关,进出分支判据不一致(plugins/hermes/install-hermes.sh:800)
- 问题:进入补齐分支的判据是
--check --strict(可选模型缺失也算不齐),而分支内真正执行的下载调用没有--strict。下载器在只有可选模型失败时返回 0(scripts/fetch_models.py:562-575),于是if !永远不成立。 - 影响:因为三个可选模型缺失而进入补齐分支、这三个又没下下来时,脚本一条告警都不打、退 0。用户看到的是"感知模型不齐,按清单补齐"之后一片沉默,每次重跑完全一样;语义去重与语音活动检测就此静默降级。这与该函数上方
:684-685自己写的标准("缺任何一个(含可选)都判不齐,否则会静默降级")直接矛盾,也正是上一轮"下载兜底形同虚设、零告警报成功"那条的失效形态。同一份下载器在构建脚本里的调用(scripts/build.sh:351)是传了--strict的,这是本次唯一漏传的调用点。 - 建议方案:
:800补--strict;或下载后重新跑一次就绪判定,不齐则告警。
🟡 值得修(不阻塞)
1. 无法读取清单的分支仍声称"已按清单齐全"
- 问题:就绪判定在脚本旁边没有 checkout 时会退回弱判据"目录里有没有 onnx"(
plugins/hermes/install-hermes.sh:688-693),这在该处境下是无从更强的——清单文件本身也不在手边。但短路分支打印的是"已按 lock 齐全"(:707-708),而这条分支根本没有读过清单。 - 影响:轻量重跑模式的唯一调用方是从安装包解出的目录调起,那里不含
scripts/,所以该分支恒走弱判据。目标目录里只要有任意一个 onnx(哪怕是零字节、哪怕只有五分之二),就会打印一句不成立的成功提示后退 0。上游有两道护栏收窄了触发面(发行包由严格模式加逐名断言保证齐全,解压出零个模型会硬失败),但部分填充的目录不在这两道之内。比改动前更退一步的地方在于,改动前至少没有这句肯定的断言。 - 建议方案:弱判据分支改口,例如"未按清单校验(旁边没有下载器)";不必改变行为。
2. 测试环境隔离只剥了自家前缀,代理变量未处理
- 问题:测试入口现在会剥掉自家的模型相关环境变量,这条闭环了;但上一轮同一条里点名要自查的代理变量没有处理。下载器用的是默认 opener(
scripts/fetch_models.py:326-327),默认带代理处理器,而标准库对回环地址没有隐式豁免。 - 影响:在设了代理的开发机上,三条起本地 HTTP 服务的用例请求会全部出网、本地服务一次都收不到,用例转红——与上一轮描述的症状同形,且只砸在内网开发者身上,托管 runner 上看不到。更隐蔽的是那条进程内直接调下载函数的用例:它连回环端口都连不上才是预期,实测却真发出了三次外网请求,而断言照常通过——"不联网"这条契约被静默违反且无任何信号。另外该平台上代理还会从系统配置读,纯靠剥环境变量堵不住。
- 建议方案:测试入口连代理变量一起剥,或注入不走代理的设置;更彻底是下载器显式使用不带代理的 opener。
3. 环境变量指定目标目录的用例仍未把写操作关进临时目录
- 问题:上一轮那条的后半句(把这次写操作关进临时目录)没有落地,该用例与改动前逐字未变,仍然只传清单路径、不传目标目录参数。
- 影响:新增的"命令行参数压过环境变量"用例只覆盖一个方向——它传了目标目录参数,参数在最前面短路,环境变量分支在不在它都是绿的。把下载器里那个环境变量分支去掉后,实测该新用例依然通过,而旧用例转红并把两个假模型写进了仓库里那个已被忽略、
git status看不见的目录。上一轮建议的写法一次拿到两个方向,现采用的机制只拿到一个。 - 建议方案:给该用例补上目标目录参数。
4. 资产对账门禁排在下载步骤之后,认证的是下载后的磁盘状态而非缓存状态
- 问题:零下载对账门禁已经落地,分级强制(推送与改了清单的 PR 强制、其余降级为告警)也有完整理由,这部分对上一轮的意图是合理再解释。但缓存这一段的步骤顺序是"恢复 → 下载 → 门禁 → 保存":下载会先把磁盘补齐,门禁再看到的是补齐后的状态。命中一份残缺缓存时,门禁看到的是完整目录、判绿;而保存步骤又被"缓存命中即跳过"挡掉。
- 影响:上一轮"残缺缓存被不可覆盖的 key 钉死、命中缓存时不联网的承诺静默失效"这层机制没有变,只是不再影响测试任务的正确性判定(门禁按摘要复核全部五个)。可核查的旁证:当前这个 key 下的缓存条目创建于本 PR 第一个 commit 之后约三分钟,也就是改动前那套一体式缓存、无门禁的配置写下的;清单文件的 blob 在首个 commit 与当前 head 之间完全相同,所以 key 全程没变;当前 head 那次运行的最后访问时间就在该 commit 之后不到一分钟——只做了恢复、命中、跳过保存。也就是说两步式保存在这个分支上一次都没有执行过,CI 全绿不构成新缓存路径可用的证据。合入后目标分支的缓存作用域不同,会新建一条受门禁的条目,所以这是分支范围的现象。
- 建议方案:认了这条取舍也可以(正确性由门禁兜住),但值得在描述里点明"门禁认证的是下载后状态";若要根治,把就绪判定挪到下载之前、不一致时强制重存,或给 key 加盐。
🔵 minor / 打磨
- 分块传输或不给 Content-Length 的源上,读取不完整抛出的异常不属于当前捕获的异常族(它不是
OSError也不是URLError的子类),会穿出scripts/fetch_models.py:411变成 traceback 加退 1,而退 1 在本文件的契约里是"必需模型缺失"、安装脚本会据此叫用户重试——正是这个文件其余部分极力避免的错误信号。把http.client.HTTPException加进该元组即可。 - 严格开关的帮助文案仍写"打 release tarball 时用"(
scripts/fetch_models.py:496),而现在有四处非发布场景在用它(测试任务门禁、本地自检、安装脚本就绪判定、构建脚本)。这是修复缓存门禁那条时顺带产生的口径漂移。 - 清单里的文件名若被写成非字符串,名字守卫会抛
AttributeError,而主流程的捕获元组不含它,于是变成 traceback 加退 1,而不是既定的用法错退 2。需要一份手工写坏的清单才能触发。 - 发布脚本里有两处注释仍在断言修复前的行为(说重算会把 Release 上的残留重新收进清单),就写在实现了该守卫的同一个文件里(
scripts/publish_models.sh:201、:250),会把下一个维护者引向相反的结论。 - 抢锁失败方走私有半成品文件之前不复查就绪状态,并发两方会各下一份完整体积;跨主机共享挂载上文件锁可能退化成节点本地语义。都不产生坏产物,只是重复付费——现有链路不涉及跨主机共享挂载。
🧪 测试缺口
- 清单缩表守卫本身没有回归保护,这是最值得补的一条。守卫那十行是同时关掉"用不完整目录重算导致静默缩表"和"把 Release 上的残留盲收进清单"两个口子的唯一机制,而两条重算用例都绕开了它:一条文件集与旧清单全等(守卫不开火),另一条显式开了放行变量(守卫被旁路);上传路径那条在动 Release 之前就中止了,根本到不了重算函数。把那十行删掉,整套测试仍然全绿。建议在文件集漂移那组用例上补一个参数化分支,直接驱动"重算 + 漂移目录 + 不设放行变量"。
- 换源变量的独占性只被证明了"会被使用",没有被证明"清单里的源不会同时作为回落再试一遍"——把清单里的源文件删掉的写法,对一个"先环境变量、失败再回落清单"的错误实现同样会通过。镜像故障转移也只有手工实测记录,没有自动化用例。
- 安装脚本那段控制流没有任何自动化覆盖,而上面两条 gating / 值得修都出在这一段。
✅ 确实做得好
- 就绪判定只认摘要、字节数只用于线上对账,两个判据分开是对的;丢弃半成品用截断而非删除以保住锁所在的 inode,这个易错点有专测真绑。
- 截断检测比的是响应头而不是清单里的 size,避免了陈旧清单下的反向误导,并且是抛异常而非返回,半成品得以保留、下一轮真的带 Range 续传——用"重试后半成品恰好等于三倍分片"作字节数代理来钉这件事,很扎实。
- 换源变量是独占替换而不是追加候选,杜绝了内网场景下悄悄回落公开镜像。
- 清单缩表的守卫落在重算函数体内而不是只挂在上传子命令上,两条调用路径一并覆盖;上传前另有一道更早的守卫,在动线上资产之前开火。
- 打包从整目录 glob 改成按清单里的名字逐个打,残留资产进不了发行包。
- 判定门禁强度那段刻意避开了"管道加静默匹配"的写法,因为命中即退会让前一段吃到 SIGPIPE、在默认开启管道失败传递的 shell 里恰好反判——这个坑注意到了。
- 文档口径这一轮订正得很彻底:可选模型缺失后的真实降级行为、换源变量的独占语义、配置里那个此前写错的键,以及两份知识库文档与去重模块 docstring 里"包内目录作兜底"的过时表述,都改到与代码一致。
结论
差一步。点 Approve 前需要:补齐模型的下载调用补上严格开关(一个词),以及无法读取清单的分支不要再声称"已按清单齐全"(一行文案)——这两处合起来才让上一轮那条真正闭环。🟡 里的测试隔离两条与缓存门禁那条,以及 🧪 里清单缩表守卫的回归保护,建议作为紧接着的 follow-up;🔵 各条随手带上即可。
做了什么
感知的 5 个 ONNX 模型(合计 78MB)从 git 移出,改为托管在固定 tag
models的 GitHub Release(已标 prerelease,不顶替 Latest),按 sha256 锁定后由脚本按需拉取。为什么
模型躺在 git 历史里有两笔持续成本:
actions/checkout默认浅克隆(fetch-depth: 1),每跑一次就白付一遍 78MB;改成 Release 资产后这两笔都归零,而且下载源可以走镜像(国内直连 GitHub 常年不稳)。
怎么拿模型
scripts/models.lock.jsonrelease_tag/base_url/mirrors+ 每个文件的 size、sha256、是否必需scripts/fetch_models.py.part→os.replace)、Range 续传、每源 3 次退避重试(file://源跳过退避——本地读不到不会因为等一会儿就变成读得到)、就绪判据只认 sha256(与落地校验同源,见_is_ready)。退出码0就绪 /1必需模型缺失 /2用法或 lock 错误。换源变量的取值先规范化再用:裸路径兜底成file://,非法 scheme 与 lock 里的裸路径都收敛成退 2(而不是穿出 main 打 traceback)scripts/publish_models.shupload <dir>上传并同步 lock(上传前先比对文件集,不一致就中止、不动线上资产);refresh-lock按 Release 现状重算 lock;verify零下载对账 Release 资产与 lock —— CI lint job 跑的就是它fetch_models.py刻意只用标准库 —— 它被build.sh、CI、install-hermes.sh直接以python3调用,多一个第三方依赖就多一处装不上的可能。面向终端用户的大包下载仍走install.py的 httpx 实现,两者互不影响。下载源 =
base_url(GitHub 直连)+mirrors(gh-proxy.com / gh-proxy.org / gh.idayer.com),与scripts/manifest.json的download.sites同源同序,直连失败自动降级到镜像。测试test_lock_sources_match_manifest_sites钉死两边一致,避免以后只改一边。接入点
scripts/build.sh的pack_models:原来"缺.onnx就 FATAL",现在跑fetch_models.py --strict(可选模型缺失也算失败 —— 不能因一次网络抖动就发出个少了 bge / VAD 的 tarball)。tar 从-C dir .改成按 lock 逐条点名:models是可变 tag,Release 上可能残留换代前的旧资产(upload只--clobber同名的、不删旧的),glob 会把它们一并打进安装包 —— 包变胖是小事,某个模型若因质量 / 许可 / 安全原因下线却继续随包分发是大事;顺带也不用再防README.md和*.part了。该步受should_build "miloco"门禁:不含后端的子集构建(如--packages miloco-cli)跳过,免得只想要个 CLI wheel 却被--strict拖去下 78MB。注意别收紧成"仅全量" —— 模型 tar 的消费方除了平台归档还有install.py的 dev 通道(直接 glob 仓库dist/),卡死会让「--packages <子集>再install.sh --dev」在 wheel 全装完之后才失败。另外--help从写死的sed -n '5,15p'改成打到抬头注释块结束(与publish_models.sh/sync-to-remote.sh同一写法)—— 本 PR 自己就往那段抬头里插过行,写死行号会把结尾「退出码:」那行无声截掉;顺带修掉 macOS 上一直存在的显示 bug:原来sed 's/^# \?//'的\?是 GNU 扩展,BSD sed 当字面问号,于是每行帮助都还挂着#。ci.yml/release.yml:actions/cache/restore+ 显式下载 +actions/cache/save拆两步,保存受cache-hit != 'true'门禁。一体式actions/cache的 post 步骤不看 job 成败,一次只下到 4/5 的运行会把残缺目录固化进那个 key 且再也盖不掉。key 只跟 lock 走;path 只 glob*.onnx/*.json,否则 key 不变时旧缓存会盖掉 checkout 出来的新 README。CI 必须下模型,否则test_deep_sort_v12.py的requires_models那批会静默 skip、等于偷偷少跑一批覆盖。ci.yml的 lint job 新增零下载对账门禁(publish_models.sh verify):models是可变 tag,资产被换而某分支 lock 未刷新时,构建失败会被缓存变成"同一 commit 今天绿、下周红"的间歇现象;一次 API 调用把它变成确定性失败点。判红范围按"能不能修"分流 —— push 与改了 lock 的 PR 强制,其余 PR 降级为告警(换代窗口内无关 PR 会被判红,而两条修法都要仓库 write 权限,外部贡献者做不到)。plugins/hermes/install-hermes.shstep 4.7:两处印给用户的补齐 / 修法命令都改成「在那个处境下真的能执行」的形式 —— 下载失败分支补上解释器(该文件在 git 里是100644、没有可执行位,裸路径粘贴是Permission denied、退 126);--post-install分支则拆掉${FETCH_MODELS:-scripts/fetch_models.py}这个兜底 ——:-只在变量为空时取用,而变量为空的充要条件正是「本脚本旁边没有 checkout」,也就是那条相对路径必然解析不到的处境(该分支的唯一调用方install.py正是从 tarball 解出的目录调的,那儿只有本脚本和插件目录),兜底值只在它必然错的时候才出场;改成按FETCH_MODELS是否可用分叉,不可用时给「重跑install.sh」(安装包自带miloco-models-*.tar.gz,解到$MILOCO_HOME/models/)。两处的共同点是:该文件在 git 里是100644、没有可执行位,裸路径原样粘贴是Permission denied(退 126),而这个新错误跟下载失败毫无关系,只会把人往「权限 / 文件损坏」方向带偏,偏偏用户此刻手上只有这一行线索。三级取模型 —— 本地现成.onnx→cp(不联网);没有 → 按 lock 下载到$MILOCO_HOME/models/;再失败 → warn 但不中断(感知降级,插件其余照装)。两处判据都收紧了:搜源目录时从"目录存在"改成"目录里真有.onnx"(新 clone 出来这目录只剩一个 README);判目标目录是否已装则从"有没有.onnx"改成"按 lock 齐不齐"(--check --strict,与最后那道门禁复用同一个判据)——按"有没有"的话,目标目录里躺着 1 个silero_vad.onnx就会跳过整段 cp,而门禁照样判不齐,于是联网下 ~75MB,可那 4 个文件就在旁边的 checkout 里;断网时更糟,只 warn 不中断、安装报成功、首次 perceive 直接models_missing。scripts/local-ci.sh:--check --strict --quiet非致命提示,防本地覆盖率悄悄比 CI 低。带--strict是因为可选模型(bge 去重 / VAD)缺失时用例并不 skip,而是走降级分支——跑的不是 CI 那条路径,不提示就看不出来。校验目录与印出来的补齐命令同源(相对路径写一次、绝对路径由它派生):两者分家时,导出了MILOCO_MODELS_DEST的开发者照抄那条裸命令会把 78MB 下到仓库外,而告警下次一字不差地再来一遍、requires_models那批照旧整批 skip(skip 判据是__file__推出来的包内目录,环境变量够不着),手里唯一的线索恰恰是那条无效命令。.gitignore:忽略该目录、只放行README.md(目录内 README 说明怎么拿模型)。dev-guide.md(模型怎么拿 / 换源 /git clean后要重 fetch / 维护者换模型流程)、sdk-onnxruntime.md、包内perception/models/README.md(这个目录为什么只剩 README)、以及troubleshooting.md的models_missing行 —— 那行原来只写「重跑install.sh」,模型移出 git 之后源码开发者照做要为补两个 onnx 装一遍安装包,补上fetch_models.py --dest这条直路。终端用户与运行时不受影响:release 包仍带
miloco-models-*.tar.gz,install.py的 download 步解到$MILOCO_HOME/models/;运行时按两段式解析,两段各判各的 —— 第一段由directories.models决定,配了就用它(相对路径按$MILOCO_HOME解析),留空则取$MILOCO_HOME/models,两种情况都不是包内perception/models/(生产链路上perception/client.py总会把这一段的结果填进perception_model_dir);第二段的包内目录只有完全不传perception_model_dir时才走得到(测试 / 临时脚本),而 wheel 里该目录必然是空的(pyproject.toml已 exclude)。验证
--version 2026.8.4):exit 0,pack_models从真 Release 拉齐 5 个模型且逐个 sha256 通过,cmp与原文件 5/5 byte-identical,无残留.part。产出miloco-models-2026.8.4.tar.gz(61M),tar -tzf成员正好 5 个模型(./前缀),与改动前产物逐一对齐(旧归档多出来的正是./和./README.md)。local-ci.sh,跑之前先用fetch_models.py把模型拉齐):覆盖含感知链路在内的全量用例,无本改动引入的失败;余下的是node_monitorsmaps 那批 macOS 已知项(local-ci.sh里显式容忍,macOS 没有/proc/self/smaps)。这里真正要盯的不是通过数,而是requires_models那批有没有被静默 skip —— 模型不在位时test_deep_sort_v12.py会整批跳过,覆盖率悄悄比 CI 低却看不出来,local-ci.sh因此加了--check --strict --quiet提示。test_fetch_models.py41 条契约测试:用file://假 Release 覆盖首下 / 二次离线 / 损坏重下 / env 换源 / hash 不符不留.part/ 可选缺失只降级 /--strict变硬失败 /--check不联网 / 用法错误退 2 / 路径穿越被拒;另起本机 HTTP 服务覆盖两条续传语义 —— 干净 FIN 截断不被误报成 hash 不符,以及.part活过进程退出、下一次调用带 Range 接着下;另加一条 inode 级断言,钉死"丢弃.part内容后_open_part拿到的 flock 仍然管用"(flock锁 inode 不锁路径,unlink会让锁脱靶)—— 这条把os.truncate改回unlink就立刻红。另起一台按 RFC 回 416 的本机服务,钉死「续传起点越界必须归零重下」:lock 比线上资产长、目录里又留着落在两者之间的.part时,Range 起点会越过资产末尾,把 416 当成「连接断了」保住.part会让每次重试、每个源、以及之后每一次调用都原样复现同一个 416,永不自愈。再加一条钉死「两条路径判据必须同源」:lock 里size与sha256描述的不是同一份字节时,下载与--check --strict必须给同一个结论,否则 CI 上紧挨着的两步一绿一红,而红的那步给的修法正是刚跑成功的上一步 —— 把_is_ready里的size预判塞回去即变红。另一条钉住「本地源不许干等」:file://上读不到就是读不到,退避是纯粹的白等,而唯一会撞上它的调用方(install-hermes.sh拿旁边 checkout 当源同步)恰好是静默的 —— 源目录只带一部分模型是常态,缺的每个白等 1s+2s,实测表现成安装器无声卡住 12.2s。用例走进程内记time.sleep调用(跨进程只能拿墙钟阈值去赌 CI 负载),断言file://不退避、重试次数不变、http源仍是[1, 2]。最后三条钉住换源变量的取值契约:裸路径(挂载目录)按file://收下并真的下成功、非http/https/file的 scheme 退 2、而 lock 里写裸路径退 2 且文案指向 lock —— 三条都断言 stderr 里没有Traceback。把_sources那段 revert 掉三条一起红。最后八条钉住「lock 能解析、但下载器依赖的键坏了」也必须收敛成一行中文 + 退 2:sha256缺失 / 非字符串 / 空串 / 截断 / 非十六进制、size是字符串 / 负数,七种参数化各断言退 2 且 stderr 无Traceback—— 退 1 的话含义是「必需模型缺失」,install-hermes.sh的四分支门禁会照这个含义让用户「稍后重试」,而重试多少次都没用。第八条反向钉死大写摘要要照常认(certutil -hashfile的输出就是大写),否则每个文件都判「校验不通过」,表现成永不收敛的重下循环。再加两条钉住「可选键写成null必须与整条不写同义」——.get(key, default)的默认值只在键不存在时生效,键在值是null时原样返回None,两个可选键都从各自的判据下面绕过去,后果还不一样:size: null漏进_human()比大小 → traceback + 退 1;而required: null是bool(None)→False,必需模型被静默降级成可选,少一个必需模型的包能一路退 0 发出去 —— 比炸掉更糟,因为没有任何人收到信号。把_check_spec里那段归一化 revert 掉两条一起红。test_publish_models.py24 条契约测试:假gh记录每次调用,覆盖verify的全等 / 缺资产 / 多资产 / sha256 不符 / size 短路、digest 缺失与非 sha256 两种降级(都只告警不判红),以及upload文件集漂移时在动线上资产之前中止(断言release upload|create一次都没发生)。另加两条钉住「cmd_verify函数体内不许有 exit」这个性质:upload收尾那次对账是故意非致命的,模拟上传途中 token 过期 / API 502,必须仍打印「别忘了提交 lock」并以 0 退出;同时直接跑verify(CI lint job 用的就是它)在gh不可用时仍须非 0 退出,确保门禁没被顺手摘掉。再补两条给此前零覆盖的refresh-lock,钉死required的保留口径:同名旧条目漏写required时保留为必需(与fetch_models._required缺键 fail-closed 同口径),显式false的原样保留,真正新增的文件仍默认false。第一条把prev.get("required", False)改回去就红 —— 而它红的那条路径「文件集全等、护栏不开火、rc=0 无任何输出」正是最看不见的一条。最后 5 条钉死坏 lock 的报错口径:合并冲突标记这种非法 JSON 在upload/refresh-lock/verify每个子命令上都必须是一行中文 + 非 0(不许 traceback、不许走到任何不可逆写操作),release_tag为空串单独一条,--help则必须照常打得出来。子命令那一轴是要害:lock 有 4 处读取点,只把顶层那次TAG=延后、再按「这个子命令用不用得上 tag」决定要不要校验的话,refresh-lock <dir>恰好被豁免(它确实不需要 tag),而refresh_lock_from_dir内部照样json.loads同一份文件 —— 实测那种改法下这一条独红,而「合并完先跑一次 refresh-lock」正是最容易撞上冲突标记的路径。206(支持 Range);把base_url改成不存在的域名后真跑,直连 3 次失败 → 自动切 gh-proxy.com → sha256 通过 → exit 0。ruff check通过;改动的 shell 脚本bash -n干净,shellcheck -S warning无本 PR 引入的新告警(build.sh里 2 处 SC2010 是 main 上既有、且不在本 PR 的 diff 内)。已知取舍
models是固定 tag、资产可变:手动换掉资产后,老 commit 里锁的 sha256 就对不上、老 commit 会构建失败。换完请跑bash scripts/publish_models.sh refresh-lock同步 lock。若要"老 commit 永远可构建",改用models-v2这类不可变 tag 并同步 lock 的release_tag/base_url。git filter-repo)需要所有人重新 clone,另议。