[{"data":1,"prerenderedAt":1853},["ShallowReactive",2],{"\u002F2026-05-27":3,"\u002F2026-05-27-rel":611},{"id":4,"title":5,"body":6,"column":594,"date":595,"description":596,"extension":597,"hero_image":598,"meta":599,"navigation":289,"path":600,"seo":601,"series_id":598,"severity":598,"stem":602,"summary":603,"tags":604,"__hash__":610},"posts\u002F2026-05-27-步进升级为什么增量包不能跨版本跳.md","跳过一个撤回的版本，客户就少了个 DLL",{"type":7,"value":8,"toc":581},"minimark",[9,18,22,29,39,42,50,53,56,62,65,71,77,81,84,90,94,97,103,110,113,117,124,127,130,133,136,141,144,150,160,165,176,181,192,196,199,203,214,218,226,230,241,247,254,257,260,263,303,306,309,315,321,324,327,388,391,503,510,525,528,539,546,571,577],[10,11,12,13,17],"p",{},"1.0.7 客户升级到 1.0.9 后启动崩溃，报 ",[14,15,16],"code",{},"STATUS_DLL_NOT_FOUND","。根因在于增量包的构造逻辑与发版序列的交互：任何\"中间 GA 版本被撤回且后续版本以它作为 baseline\"的组合，都会让跳过撤回版本的客户拿到不完整的增量包。",[19,20,21],"h2",{"id":21},"增量包的设计前提",[10,23,24,25,28],{},"OTA 更新包是二进制差分产物——",[14,26,27],{},"release.sh"," 在构造增量包时，只列入「FROM → TO 两个相邻版本之间 sha256 实际不同的文件」。以 1.0.8 → 1.0.9 的增量为例：",[30,31,36],"pre",{"className":32,"code":34,"language":35},[33],"language-text","1.0.8 dist ↔ 1.0.9 dist\n  vcruntime140.dll (sha256: abc...)  vcruntime140.dll (sha256: abc...)\n                                    ↑ 一致，不进差分包\n  msvcp140.dll (sha256: def...)     msvcp140.dll (sha256: def...)\n                                    ↑ 一致，不进差分包\n  launcher.exe (sha256: old)        launcher.exe (sha256: new)\n                                    ↑ 不同，进差分包\n","text",[14,37,34],{"__ignoreMap":38},"",[10,40,41],{},"相比全量包（500+ MB），增量包通常只有 100-200 MB。对于国内弱网、公网、海外长途网络来说，包体积直接影响下载时间和失败率。选择差分包的代价是必须确保客户的升级链路完整。",[10,43,44,45,49],{},"这个设计基于一个隐含的假设：",[46,47,48],"strong",{},"客户严格按发布顺序逐版升级","（1.0.7 → 1.0.8 → 1.0.9 → ...）。每跳一个版本，就叠加一次「FROM→TO」的相邻增量。所有累积变更都会被依次应用，最终到达完整的目标版本。只要这个假设成立，差分机制就是 sound 的。但一旦中间版本被 recall 且升序逻辑允许跳过，假设就被破坏了。",[19,51,52],{"id":52},"发版序列与撤回的交互",[10,54,55],{},"关键的发版序列是这样的：",[30,57,60],{"className":58,"code":59,"language":35},[33],"1.0.5  GA   无 VCRuntime DLL\n1.0.6  recalled\n1.0.7  GA   无 VCRuntime DLL\n1.0.8  GA   ↓ 首次 Copy-VcRuntime，dist 含 9 个 VC++ Runtime DLL\n       后 recalled（因 OTA_TRASH_CORRUPT 现场卡死）\n1.0.9  GA   继承 1.0.8 build，含 VCRuntime DLL\n       baseline = 1.0.8（diff 算相邻两版）\n       minClient = 1.0.8（改为 1.0.7 后触发此 bug）\n",[14,61,59],{"__ignoreMap":38},[10,63,64],{},"当 1.0.9 的 minClient 被改为 1.0.7 后，升序逻辑会为 1.0.7 客户返回 1.0.9 的 manifest。minClient 是一个关键的版本约束字段——它表示\"升级到当前版本前，客户必须至少是什么版本\"。修改 minClient 的动机通常是想扩大升级适配范围（比如从 1.0.8 放宽到 1.0.7），但这个改动隐含了一个假设：\"当前版本及其所有依赖都能兼容从 minClient 开始的所有中间版本\"。在这个案例中，这个假设被打破了。",[30,66,69],{"className":67,"code":68,"language":35},[33],"G26 升序查询（1.0.7 客户，seek current 之后最小 GA）：\n  候选 1.0.8 → recalled，跳过\n  候选 1.0.9 → GA，minClient=1.0.7≤1.0.7 满足 → 命中，返回 1.0.9 manifest\n",[14,70,68],{"__ignoreMap":38},[10,72,73,74,76],{},"1.0.7 客户 apply 1.0.9 的差分包后，系统启动时 launcher.exe 试图加载 VCRUNTIME140.dll，但客户机的 dist 里没有这个文件——1.0.8 引入的 9 个 DLL 永远没有被应用过。Windows 加载器在 LoadLibrary 阶段直接终结进程，报 ",[14,75,16],{},"。",[19,78,80],{"id":79},"为什么-107-看起来能启动升级后就崩溃","为什么 1.0.7 看起来能启动，升级后就崩溃",[10,82,83],{},"这里需要澄清一个关键点。1.0.7 的 dist 里确实没有 VCRuntime DLL，但 1.0.7 版本的应用仍然能启动。原因在于 launcher.exe 是用 1.0.7 时代的 toolchain build 的，它依赖的 MSVC CRT 版本恰好在干净 Windows 的 System32 里能找到（或者该客户机预装了对应版本的 Visual C++ Redistributable）。这是一个\"侥幸\"——1.0.7 构建时选用的工具链版本较旧，CRT 的 ABI 足够稳定，系统库能满足。",[10,85,86,87,89],{},"一旦升级到 1.0.9，launcher.exe 是用更新的 toolchain build 的，它依赖的 CRT 版本更新了，链接的 API 集合也有变化。客户机的 System32 里没有这个新版 CRT。Windows PE 加载器会在 LoadLibrary 阶段就找不到 VCRUNTIME140.dll，进程在主函数之前被系统杀掉（",[14,88,16],{},"）。应用的错误处理机制根本没机会启动——没有 try-catch，没有日志，只有系统错误弹窗和退出码。",[19,91,93],{"id":92},"为什么会漏-dll","为什么会漏 DLL",[10,95,96],{},"这是差分包设计与版本跳过的碰撞。1.0.9 的差分包不包含 vcruntime140.dll，原因看似无害：",[30,98,101],{"className":99,"code":100,"language":35},[33],"1.0.8 dist                1.0.9 dist\n  vcruntime140.dll          vcruntime140.dll\n  sha256: \u003Chash>            sha256: \u003Chash>（同一个 build 产物，哈希相同）\n  → 两边都有，且相同 → 不进差分包\n",[14,102,100],{"__ignoreMap":38},[10,104,105,106,109],{},"但这个逻辑基于一个前提：",[46,107,108],{},"从 1.0.8 升级来","。如果客户是从 1.0.7 升级，那么它的 dist 里根本没有 vcruntime140.dll，差分包也不会帮它补上——因为 1.0.9 diff 算法看的是\"1.0.8 有且 1.0.9 也有\" 的文件。",[10,111,112],{},"任何「中间 GA 版本引入新文件 + 该版本被 recall + 后续版本用它作 baseline」的组合，都会造成同样的问题。这不是 DLL 特有的现象，而是增量包机制本身的约束。",[19,114,116],{"id":115},"为什么-108-会引入-dll","为什么 1.0.8 会引入 DLL",[10,118,119,120,123],{},"顺便说一下 1.0.8 为什么成为\"罪魁祸首\"。1.0.8 是第一个在 build-portable.ps1 中加入 ",[14,121,122],{},"Copy-VcRuntime"," 的版本，它主动把整套 MSVC redist DLL（9 个）旁置到 dist，这样便携包就不再依赖系统级安装的 Visual C++ Redistributable。这个改动本身是对的，但由于 1.0.8 随后因 OTA_TRASH_CORRUPT 问题被 recall，它成了\"引入新文件但被撤回\"的典型。",[10,125,126],{},"1.0.9 继承了 1.0.8 的 build，自然也含有这 9 个 DLL，但 1.0.9 的差分包相对 1.0.8 计算，所以这些 DLL 不会再被列入。结果就是：1.0.7 客户跳过 1.0.8，直接升到 1.0.9，永远收不到这些 DLL。",[10,128,129],{},"同时，还有一个独立的关联问题：修复脚本 Fix-VCRuntime.bat 本身因为编码问题（UTF-8 vs GBK 的冲突）无法正常执行，所以即使用户尝试手动修复，脚本也会报错。这是打包流程和脚本编码的两个独立根因，在同一时间窗口内暴露了出来。",[19,131,132],{"id":132},"修复方案的权衡",[10,134,135],{},"面对这个问题，有两条主要路径可选：",[137,138,140],"h3",{"id":139},"方案-a全量兜底","方案 A：全量兜底",[10,142,143],{},"跨版本升级时直接下发完整 full.zip，而不是差分包。",[10,145,146,149],{},[46,147,148],{},"优点","：",[151,152,153,157],"ul",{},[154,155,156],"li",{},"简单粗暴，一个包包含所有文件，不存在漏 DLL 的可能",[154,158,159],{},"不需要调整发版流程",[10,161,162,149],{},[46,163,164],{},"缺点",[151,166,167,170,173],{},[154,168,169],{},"包体积大（差分包通常 100-200 MB，全量 500+ MB）",[154,171,172],{},"客户网络环境差时升级时间长，失败率高",[154,174,175],{},"CDN 带宽成本增加",[10,177,178,149],{},[46,179,180],{},"适用场景",[151,182,183,186,189],{},[154,184,185],{},"一次性应急（发 hotfix 回滚）",[154,187,188],{},"用户基数小、网络条件好",[154,190,191],{},"跨度非常大（比如 1.0.1 → 2.0.0）的少见场景",[137,193,195],{"id":194},"方案-b严格步进","方案 B：严格步进",[10,197,198],{},"服务端按发布链下发下一个版本，不允许跨级。即使是 recalled 版本也保留在链上，只是不作为升级终点。",[10,200,201,149],{},[46,202,148],{},[151,204,205,208,211],{},[154,206,207],{},"包体积小，增量包通常 100-200 MB",[154,209,210],{},"每个客户走相同的升级链路，问题复现容易排查",[154,212,213],{},"充分利用增量包设计的初衷",[10,215,216,149],{},[46,217,164],{},[151,219,220,223],{},[154,221,222],{},"升级链路长（1.0.7 → 1.0.9 时需要先升 1.0.8，即使 1.0.8 被 recalled）",[154,224,225],{},"如果某个中间版本有严重问题（比如 OTA 卡死），客户升级时仍会卡在它上面",[10,227,228,149],{},[46,229,180],{},[151,231,232,235,238],{},[154,233,234],{},"绝大多数常规发版",[154,236,237],{},"网络条件多变的客户群",[154,239,240],{},"需要严格控制升级质量的生产环境",[10,242,243,246],{},[46,244,245],{},"为什么选择 B","：有几个关键因素。首先，客户群分布广（企业内网、弱网、移动网络混合），300+ MB 的全量包在弱网场景下升级失败率会明显增加，反而增加了支持成本。其次，一旦引入全量兜底，就容易形成\"跨版本时总是下发全量\"的惯性，长期来看放弃了增量包的设计初衷。最后，严格步进虽然升级链路长，但每个版本的 DL + apply 成本都是可预测的，失败时也容易重试。",[10,248,249,250,253],{},"通过让 recalled 版本保持在链上（只改为 non-GA 状态），升序逻辑会自动跳过它们，下一个 GA 版本会包含完整的累积增量。1.0.9 被 recall 后，G26 升序对 1.0.7 客户会自动推荐 1.0.10；1.0.10 的差分是相对 1.0.7 算的（通过 ",[14,251,252],{},"FROM_VERSION=1.0.7"," 显式指定），包含了 1.0.8 引入的 9 个 DLL 以及后续的所有变更。这样既保持了包体积小的优势，又确保了跨越 recalled 版本的客户能拿到完整增量。1.0.10 的差分包最关键的是 Removes 为 0——任何跨版本的客户 apply 都不会留下废文件。",[19,255,256],{"id":256},"为什么撤回版本不能从链上摘掉",[10,258,259],{},"这里有一个容易忽视的细节：即使某个版本被 recall，也不能从版本链中物理删除。",[10,261,262],{},"如果把 1.0.8 从数据库里删掉，升序逻辑会直接跳过它，导致 1.0.7 客户被推荐到 1.0.9——此时就回到了原来的问题。修复的关键是让升序逻辑仍然\"看到\" 1.0.8 的存在（用于确定差分链路），但把它的 rollout 状态改为 non-GA，使得它永远不会被作为升级终点。",[30,264,268],{"className":265,"code":266,"language":267,"meta":38,"style":38},"language-sql shiki shiki-themes github-light github-dark","-- 这样的做法是错的（会复现问题）：\nDELETE FROM releases WHERE version = '1.0.8';\n\n-- 正确的做法：\nUPDATE releases SET rollout_status = 'recalled' WHERE version = '1.0.8';\n","sql",[14,269,270,278,284,291,297],{"__ignoreMap":38},[271,272,275],"span",{"class":273,"line":274},"line",1,[271,276,277],{},"-- 这样的做法是错的（会复现问题）：\n",[271,279,281],{"class":273,"line":280},2,[271,282,283],{},"DELETE FROM releases WHERE version = '1.0.8';\n",[271,285,287],{"class":273,"line":286},3,[271,288,290],{"emptyLinePlaceholder":289},true,"\n",[271,292,294],{"class":273,"line":293},4,[271,295,296],{},"-- 正确的做法：\n",[271,298,300],{"class":273,"line":299},5,[271,301,302],{},"UPDATE releases SET rollout_status = 'recalled' WHERE version = '1.0.8';\n",[10,304,305],{},"换句话说，版本链的完整性是优于减少数据库记录的。每次新版本发布时，baseline 计算和 diff 生成都要以完整的版本序列为基础。所以即使 1.0.8 和 1.0.9 都被 recall，它们仍然需要在数据库中占据一个位置，作为\"这个版本存在过，但用户不应该升级到它\"的标记。升序逻辑看到 recalled 标记后会直接跳过，继续往后找第一个 GA 版本。",[10,307,308],{},"修复后的数据库状态是：",[30,310,313],{"className":311,"code":312,"language":35},[33],"1.0.7   GA         minClient=1.0.5\n1.0.8   recalled\n1.0.9   recalled   ← 这一步至关重要\n1.0.10  GA         minClient=1.0.7\n",[14,314,312],{"__ignoreMap":38},[10,316,317,318,320],{},"现在 1.0.7 客户升序时会依次检查 1.0.8（recalled，跳）→ 1.0.9（recalled，跳）→ 1.0.10（GA，命中）。1.0.10 的差分包是通过 ",[14,319,252],{}," 显式指定的，所以它包含 1.0.8 和 1.0.9 引入的所有文件。",[19,322,323],{"id":323},"预防和后续",[10,325,326],{},"立即修复是发 1.0.10 并 recall 1.0.9，已在 2026-05-11 19:42 完成。发布 1.0.10 时的关键操作是显式指定 baseline：",[30,328,332],{"className":329,"code":330,"language":331,"meta":38,"style":38},"language-bash shiki shiki-themes github-light github-dark","FROM_VERSION=1.0.7 \\\nMINISIGN_PASSWORD='...' ADMIN_PASS='...' BUILD_HOST_PASSWORD='...' \\\nROLLOUT_STATUS=whitelist \\\n  bash scripts\u002Frelease.sh 1.0.10\n","bash",[14,333,334,352,369,377],{"__ignoreMap":38},[271,335,336,340,344,348],{"class":273,"line":274},[271,337,339],{"class":338},"sVt8B","FROM_VERSION",[271,341,343],{"class":342},"szBVR","=",[271,345,347],{"class":346},"sZZnC","1.0.7",[271,349,351],{"class":350},"sScJk"," \\\n",[271,353,354,357,360,363,366],{"class":273,"line":280},[271,355,356],{"class":338},"MINISIGN_PASSWORD=",[271,358,359],{"class":346},"'...'",[271,361,362],{"class":346}," ADMIN_PASS='...'",[271,364,365],{"class":346}," BUILD_HOST_PASSWORD='...'",[271,367,351],{"class":368},"sj4cs",[271,370,371,374],{"class":273,"line":286},[271,372,373],{"class":338},"ROLLOUT_STATUS=whitelist ",[271,375,376],{"class":368},"\\\n",[271,378,379,382,385],{"class":273,"line":293},[271,380,381],{"class":346},"  bash",[271,383,384],{"class":346}," scripts\u002Frelease.sh",[271,386,387],{"class":368}," 1.0.10\n",[10,389,390],{},"这样 diff 生成引擎会计算 1.0.7 ↔ 1.0.10 的完整差异，而不是默认的\"上一个 GA\"（1.0.9）。然后执行 PATCH 和 recall 操作：",[30,392,394],{"className":329,"code":393,"language":331,"meta":38,"style":38},"# 1. PATCH 1.0.10 → ga\ncurl -X PATCH \"$ADMIN\u002Fadmin\u002Freleases\u002F$ID\u002Frollout\" -d '{\"status\":\"ga\"}'\n\n# 2. recall 1.0.9（防止升序仍然推荐给 1.0.7 客户）\nbash scripts\u002Frelease\u002Frecall.sh \u003Crelease-id>\n\n# 3. 清 redis rollout-wrapper cache（立即生效，不等待 cache 过期）\nredis-cli --scan --pattern 'cache:rollout-wrapper:*' | xargs -r redis-cli DEL\n",[14,395,396,402,434,438,443,462,467,473],{"__ignoreMap":38},[271,397,398],{"class":273,"line":274},[271,399,401],{"class":400},"sJ8bj","# 1. PATCH 1.0.10 → ga\n",[271,403,404,407,410,413,416,419,422,425,428,431],{"class":273,"line":280},[271,405,406],{"class":350},"curl",[271,408,409],{"class":368}," -X",[271,411,412],{"class":346}," PATCH",[271,414,415],{"class":346}," \"",[271,417,418],{"class":338},"$ADMIN",[271,420,421],{"class":346},"\u002Fadmin\u002Freleases\u002F",[271,423,424],{"class":338},"$ID",[271,426,427],{"class":346},"\u002Frollout\"",[271,429,430],{"class":368}," -d",[271,432,433],{"class":346}," '{\"status\":\"ga\"}'\n",[271,435,436],{"class":273,"line":286},[271,437,290],{"emptyLinePlaceholder":289},[271,439,440],{"class":273,"line":293},[271,441,442],{"class":400},"# 2. recall 1.0.9（防止升序仍然推荐给 1.0.7 客户）\n",[271,444,445,447,450,453,456,459],{"class":273,"line":299},[271,446,331],{"class":350},[271,448,449],{"class":346}," scripts\u002Frelease\u002Frecall.sh",[271,451,452],{"class":342}," \u003C",[271,454,455],{"class":346},"release-i",[271,457,458],{"class":338},"d",[271,460,461],{"class":342},">\n",[271,463,465],{"class":273,"line":464},6,[271,466,290],{"emptyLinePlaceholder":289},[271,468,470],{"class":273,"line":469},7,[271,471,472],{"class":400},"# 3. 清 redis rollout-wrapper cache（立即生效，不等待 cache 过期）\n",[271,474,476,479,482,485,488,491,494,497,500],{"class":273,"line":475},8,[271,477,478],{"class":350},"redis-cli",[271,480,481],{"class":368}," --scan",[271,483,484],{"class":368}," --pattern",[271,486,487],{"class":346}," 'cache:rollout-wrapper:*'",[271,489,490],{"class":342}," |",[271,492,493],{"class":350}," xargs",[271,495,496],{"class":368}," -r",[271,498,499],{"class":346}," redis-cli",[271,501,502],{"class":346}," DEL\n",[10,504,505,506,509],{},"预防层面，在 ",[14,507,508],{},"wakou-release"," skill 中加了三条新检测项（G27\u002FG28\u002FG29）：",[511,512,513,516,519],"ol",{},[154,514,515],{},"检查 releases 表里有没有「ga → recalled 但后续版本 baseline 还指着它」的组合",[154,517,518],{},"评估是否存在客户群可能跨过 recalled 版本拿增量",[154,520,521,522],{},"如果是，强制新版本 specify ",[14,523,524],{},"FROM_VERSION=\u003C最老活跃 GA 版本>",[10,526,527],{},"这三条检测在每次发版前自动跑，有问题的组合会在 release.sh 阶段被拦住，不允许走到\"上传 CDN\"这一步。",[10,529,530,531,534,535,538],{},"后续架构层改进（未实施）：admin server 的 G26 解析逻辑可以加「baseline 链路完整性检查」。具体是：当升序逻辑返回 manifest 给客户时，校验 ",[14,532,533],{},"client.version → release.baseline_version"," 之间的所有版本是否都是 GA（无 recalled）。如果链路中存在 recalled 版本，admin 直接返回错误码（比如 ",[14,536,537],{},"EXTRACT_FROM_OLDER_BASELINE","）给 OTA worker，让客户 fallback 到全量包请求，而不是无声给一个不完整的差分。",[10,540,541,542,545],{},"这个改进的收益是\"防御性更强\"——即使人肉操作出了问题（比如遗漏了 ",[14,543,544],{},"FROM_VERSION="," 指定），系统也能主动检测并避免分发坏包。但代价是需要修改：",[151,547,548,554,561,564],{},[154,549,550,553],{},[14,551,552],{},"release-manifest.json"," 的 schema（添加链路检查标记）",[154,555,556,557,560],{},"admin 的 ",[14,558,559],{},"update.ts::resolveBonjourCliPath"," 逻辑",[154,562,563],{},"OTA worker 的错误码处理（识别新的 fallback 信号）",[154,565,566,567,570],{},"CDN 需要同时提供 ",[14,568,569],{},"full-from-empty.zip","（或允许 diff 降级到 full）",[10,572,573,574,576],{},"鉴于目前的发版流程和人肉检测已经能防住这个问题，架构层改进被标记为后续单独需求，暂时先依赖流程检查和 ",[14,575,544],{}," 人肉兜底。",[578,579,580],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":38,"searchDepth":280,"depth":280,"links":582},[583,584,585,586,587,588,592,593],{"id":21,"depth":280,"text":21},{"id":52,"depth":280,"text":52},{"id":79,"depth":280,"text":80},{"id":92,"depth":280,"text":93},{"id":115,"depth":280,"text":116},{"id":132,"depth":280,"text":132,"children":589},[590,591],{"id":139,"depth":286,"text":140},{"id":194,"depth":286,"text":195},{"id":256,"depth":280,"text":256},{"id":323,"depth":280,"text":323},"交付与更新","2026-05-27","1.0.7 客户升级到 1.0.9 后启动崩溃，报 STATUS_DLL_NOT_FOUND。根因在于增量包的构造逻辑与发版序列的交互：任何\"中间 GA 版本被撤回且后续版本以它作为 baseline\"的组合，都会让跳过撤回版本的客户拿到不完整的增量包。","md",null,{},"\u002F2026-05-27",{"title":5,"description":596},"2026-05-27-步进升级为什么增量包不能跨版本跳","增量包是相邻两版的差分，跳过中间版本会漏掉某版本引入的文件，导致客户启动崩溃。",[605,606,607,608,609],"OTA","增量更新","版本管理","二进制差分","发版流程","mO5egKlEmvxpoLQ8R1GYBlnXzKl1bq1NoHYzI2X5h-0",[612,1103,1455],{"id":613,"title":614,"body":615,"column":594,"date":1092,"description":619,"extension":597,"hero_image":598,"meta":1093,"navigation":289,"path":1094,"seo":1095,"series_id":598,"severity":598,"stem":1096,"summary":1097,"tags":1098,"__hash__":1102},"posts\u002F2026-06-16-把高风险发版固化成skill.md","不让 AI 碰发版，不等于靠人记住每一步",{"type":7,"value":616,"toc":1086},[617,620,624,627,630,649,660,663,667,670,673,792,799,802,809,838,841,848,852,855,858,865,935,946,1008,1011,1026,1033,1036,1053,1060,1063,1066,1077,1080,1083],[10,618,619],{},"发版从来不应该由人工记忆驱动。5 月那一轮 OTA 系列故障（FA-005 到 FA-008）和跨版本步进导致的漏 DLL 事故反复说明了这一点：发版是不可逆的、影响全部用户的、细节繁琐的操作，任何一个环节遗漏或顺序错误就导致客户端卡死、文件不一致、激活页死循环。我现在不是让 AI 执行发版——那是禁地——而是把 5 个已经踏过所有坑的发版流程全部固化成 skill，让执行者用最小脑力成本跑完完整链路，而不是依赖文档翻译和记忆。",[19,621,623],{"id":622},"触发词不该触发时绝对不触发","触发词：不该触发时绝对不触发",[10,625,626],{},"一个 skill 最危险的时刻是被错误调用。\"现在是什么版本\"听起来像发版，\"改一下版本号\"听起来像发版，但它们完全不是。",[10,628,629],{},"Wakou 发版 skill 的触发词清单（必读触发词段）就是为了卡死这条线：",[151,631,632,638],{},[154,633,634,635],{},"触发：",[14,636,637],{},"发版 \u002F 发布 \u002F release \u002F 打个新版本 \u002F OTA \u002F OTA 死循环 \u002F 版本 bump \u002F \u002Fwakou-release",[154,639,640,641,644,645,648],{},"不触发：",[14,642,643],{},"改一下 package.json 版本号","（用户只想改文件），",[14,646,647],{},"看看现在是什么版本","（只查询）",[10,650,651,652,655,656,659],{},"反面教材我都经历过。改一下配置文件、改一个环境变量、查一个版本号，因为没有明确的触发词界限，最后莫名其妙走到了发版脚本的某一步。Connector OTA skill 同样列明了不触发场景：",[14,653,654],{},"\"Connector 现在是什么版本\"(只查询)"," ",[14,657,658],{},"\"改 tauri.conf 版本号\"(用户只想改文件)"," — 这些都不触发，即使命令里含了 version、release 这类关键字。",[10,661,662],{},"这条最反直觉但最重要：不该触发时被触发，等于主动送一次误操作机会给自动化流程。",[19,664,666],{"id":665},"步骤不可跳过验证链条硬卡","步骤不可跳过：验证链条硬卡",[10,668,669],{},"一旦 skill 确定要执行，每一步都必须有验证关卡，前一步没通过绝不进下一步。我用 Wakou 全自动发版流程作例子。",[10,671,672],{},"预检（Step 1）是整个流程的闸门。跑这些 Bash 检查：",[30,674,676],{"className":329,"code":675,"language":331,"meta":38,"style":38},"cd \u002FUsers\u002Fxingye\u002FAi\u002Fnew-openclaw\ngit status --short                                          # 工作区干净\ntest -x \u002Fopt\u002Fhomebrew\u002Fbin\u002Fsshpass                           # ssh 工具\ntest -x \u002Fopt\u002Fhomebrew\u002Fbin\u002Fminisign                          # 签名工具\ntest -x \u003C签名 CLI 路径>                                      # 内部签名工具就位\ntest -f \u003C发版签名私钥路径>                                   # 私钥就位\ntest -f \u003CCDN 上传凭据路径>                                   # CDN 凭据就位\n",[14,677,678,686,700,714,726,752,771],{"__ignoreMap":38},[271,679,680,683],{"class":273,"line":274},[271,681,682],{"class":368},"cd",[271,684,685],{"class":346}," \u002FUsers\u002Fxingye\u002FAi\u002Fnew-openclaw\n",[271,687,688,691,694,697],{"class":273,"line":280},[271,689,690],{"class":350},"git",[271,692,693],{"class":346}," status",[271,695,696],{"class":368}," --short",[271,698,699],{"class":400},"                                          # 工作区干净\n",[271,701,702,705,708,711],{"class":273,"line":286},[271,703,704],{"class":368},"test",[271,706,707],{"class":368}," -x",[271,709,710],{"class":346}," \u002Fopt\u002Fhomebrew\u002Fbin\u002Fsshpass",[271,712,713],{"class":400},"                           # ssh 工具\n",[271,715,716,718,720,723],{"class":273,"line":293},[271,717,704],{"class":368},[271,719,707],{"class":368},[271,721,722],{"class":346}," \u002Fopt\u002Fhomebrew\u002Fbin\u002Fminisign",[271,724,725],{"class":400},"                          # 签名工具\n",[271,727,728,730,732,734,737,740,743,746,749],{"class":273,"line":299},[271,729,704],{"class":368},[271,731,707],{"class":368},[271,733,452],{"class":342},[271,735,736],{"class":346},"签名",[271,738,739],{"class":346}," CLI",[271,741,742],{"class":346}," 路",[271,744,745],{"class":338},"径",[271,747,748],{"class":342},">",[271,750,751],{"class":400},"                                      # 内部签名工具就位\n",[271,753,754,756,759,761,764,766,768],{"class":273,"line":464},[271,755,704],{"class":368},[271,757,758],{"class":368}," -f",[271,760,452],{"class":342},[271,762,763],{"class":346},"发版签名私钥路",[271,765,745],{"class":338},[271,767,748],{"class":342},[271,769,770],{"class":400},"                                   # 私钥就位\n",[271,772,773,775,777,779,782,785,787,789],{"class":273,"line":469},[271,774,704],{"class":368},[271,776,758],{"class":368},[271,778,452],{"class":342},[271,780,781],{"class":346},"CDN",[271,783,784],{"class":346}," 上传凭据路",[271,786,745],{"class":338},[271,788,748],{"class":342},[271,790,791],{"class":400},"                                   # CDN 凭据就位\n",[10,793,794,795,798],{},"任何一条 fail，流程停止并告诉用户修什么。如果 git 工作区脏，不是自动 stash，而是问用户：工作区有未提交改动，要先 commit 还是 stash？发版会自动 bump 三个版本文件并 commit。这样做的目的很明确——让用户",[46,796,797],{},"清晰意识到","发版会改动工作区，而不是闭着眼睛走入自动流程。",[10,800,801],{},"收三个密码（Step 2）也有安全约定。不要把密码记到内存或 commit 进文件，只读 shell 的 export。如果用户在过去几分钟内说过这些密码（在 conversation context 里能直接拿到），可以直接传 env 跑；否则必须用户主动导入。",[10,803,804,805,808],{},"跑 release.sh（Step 3）用 ",[14,806,807],{},"run_in_background=true"," 避免 5-15 分钟阻塞主线，把输出 tee 到日志。然后（Step 4）用 Monitor 工具监控 10 个 stage 的进度：",[511,810,811,814,817,820,823,826,829,832,835],{},[154,812,813],{},"preflight",[154,815,816],{},"detect FROM_VERSION",[154,818,819],{},"tar + scp source to Win build host",[154,821,822],{},"build on Win",[154,824,825],{},"stage + diff-pack OTA full.zip",[154,827,828],{},"minisign full.zip + release-manifest.json",[154,830,831],{},"upload to CDN",[154,833,834],{},"admin: create release + PATCH rollout",[154,836,837],{},"git tag + commit version bump",[10,839,840],{},"每个 stage 完成给用户报一句。出现 ✗ FAILED 立刻 surface 错误。没有\"继续试试能不能救\"，就是暴露失败。",[10,842,843,844,847],{},"这整套链条的目的是",[46,845,846],{},"让人工审核点布满全流程","。不是机器自动发版，而是 AI 陪着用户一步步走完发版，每步都看得清、验得出。",[19,849,851],{"id":850},"故障处置内联把坑写在流程里","故障处置内联：把坑写在流程里",[10,853,854],{},"OTA 系列故障的教训不应该沉在 bug 复盘里。我把它们直接铺进 skill 的执行逻辑。",[10,856,857],{},"Wakou 发版 skill 有整整一章叫「🚨 阻塞性 bug 处置 SOP」。列出了从 G19 到 G31 的 11 个已知 bug：",[10,859,860,861,864],{},"G19 是 OTA modal 反复弹。原因是 state base 残留 stale ",[14,862,863],{},"download.zip.minisig","，当时的修复是：",[30,866,868],{"className":329,"code":867,"language":331,"meta":38,"style":38},"# 预检 sanity#3: data\u002F 不删\nsanity#3 强制阻断 deletes 命中 data\u002F。某次发版失败提示 sanity#3 一定是 build 步骤搞坏了 data\u002F 目录的同步，不要绕过 sanity check 去强发，先 debug 为什么 data\u002F 出现在 deletes 列表。\n",[14,869,870,875],{"__ignoreMap":38},[271,871,872],{"class":273,"line":274},[271,873,874],{"class":400},"# 预检 sanity#3: data\u002F 不删\n",[271,876,877,880,883,886,889,892,895,898,901,904,907,910,913,916,919,922,925,927,930,932],{"class":273,"line":280},[271,878,879],{"class":350},"sanity#3",[271,881,882],{"class":346}," 强制阻断",[271,884,885],{"class":346}," deletes",[271,887,888],{"class":346}," 命中",[271,890,891],{"class":346}," data\u002F。某次发版失败提示",[271,893,894],{"class":346}," sanity#3",[271,896,897],{"class":346}," 一定是",[271,899,900],{"class":346}," build",[271,902,903],{"class":346}," 步骤搞坏了",[271,905,906],{"class":346}," data\u002F",[271,908,909],{"class":346}," 目录的同步，不要绕过",[271,911,912],{"class":346}," sanity",[271,914,915],{"class":346}," check",[271,917,918],{"class":346}," 去强发，先",[271,920,921],{"class":346}," debug",[271,923,924],{"class":346}," 为什么",[271,926,906],{"class":346},[271,928,929],{"class":346}," 出现在",[271,931,885],{"class":346},[271,933,934],{"class":346}," 列表。\n",[10,936,937,938,941,942,945],{},"G25 是跨用户机器后 chat 权限错。根因是 ",[14,939,940],{},"sessions\u002Faudit"," 含绝对路径，guardian sync 扩散。这不是\"修了以后的故事\"，而是",[46,943,944],{},"发版前防御 checklist"," 的一部分：",[30,947,949],{"className":329,"code":948,"language":331,"meta":38,"style":38},"# 发版前防御 checklist (每次发版前在 build host 做完整新用户回归)\n# 3. dist 内 grep C:\\Users\\ 应该 0 命中（防 G25 类绝对路径泄露）\nssh CHANCHING@\u003C构建机> 'powershell -Command \"\n  Get-ChildItem D:\\wakou-build\\dist-X.Y.Z\\WakouPanelPortable -Recurse -File -Include *.json,*.jsonl |\n    Where-Object { $_.Length -lt 200KB } |\n    Select-String C:\\\\Users\\\\Administrator -SimpleMatch -List |\n    Select-Object FullName\n\"'\n",[14,950,951,956,961,983,988,993,998,1003],{"__ignoreMap":38},[271,952,953],{"class":273,"line":274},[271,954,955],{"class":400},"# 发版前防御 checklist (每次发版前在 build host 做完整新用户回归)\n",[271,957,958],{"class":273,"line":280},[271,959,960],{"class":400},"# 3. dist 内 grep C:\\Users\\ 应该 0 命中（防 G25 类绝对路径泄露）\n",[271,962,963,966,969,972,975,978,980],{"class":273,"line":286},[271,964,965],{"class":350},"ssh",[271,967,968],{"class":346}," CHANCHING@",[271,970,971],{"class":342},"\u003C",[271,973,974],{"class":346},"构建",[271,976,977],{"class":338},"机",[271,979,748],{"class":342},[271,981,982],{"class":346}," 'powershell -Command \"\n",[271,984,985],{"class":273,"line":293},[271,986,987],{"class":346},"  Get-ChildItem D:\\wakou-build\\dist-X.Y.Z\\WakouPanelPortable -Recurse -File -Include *.json,*.jsonl |\n",[271,989,990],{"class":273,"line":299},[271,991,992],{"class":346},"    Where-Object { $_.Length -lt 200KB } |\n",[271,994,995],{"class":273,"line":464},[271,996,997],{"class":346},"    Select-String C:\\\\Users\\\\Administrator -SimpleMatch -List |\n",[271,999,1000],{"class":273,"line":469},[271,1001,1002],{"class":346},"    Select-Object FullName\n",[271,1004,1005],{"class":273,"line":475},[271,1006,1007],{"class":346},"\"'\n",[10,1009,1010],{},"任何一项 fail → 不要 GA，修了再 bump 一个 patch 版本。",[10,1012,1013,1014,1017,1018,1021,1022,1025],{},"Connector OTA skill 的故障字典（§4）则是一张病症与病因的对照表。\"signature verification failed\" 对应的处置是检查 ",[14,1015,1016],{},"TAURI_SIGNING_PRIVATE_KEY"," 是不是 .sec 文件",[46,1019,1020],{},"base64 编码后","的内容。\"OTA 不跨级但用户期望直跳\" 的处置是调整 ",[14,1023,1024],{},"findNextVersion"," 逻辑或在灰度模式放上一个 manifest。",[10,1027,1028,1029,1032],{},"关键在",[46,1030,1031],{},"处置是写死在 skill 里","，不是留在事后复盘让人去翻。OpenClaw Runtime 发版 skill 的故障字典甚至铺了 10 条常见陷阱：BuildKit context 缓存不清导致改动没编进去、gwbridge IP 池满导致 service 无法起、孤儿 docker-proxy 占着 host port、磁盘满导致 openclaw.json 被写成 0 字节。",[10,1034,1035],{},"每一条都带着：\"现象是什么、根因是什么、怎么修\"。拿 BuildKit 缓存那条：",[1037,1038,1039],"blockquote",{},[10,1040,1041,1042,1045,1046,1049,1050,1052],{},"症状：rsync\u002FSFTP 把新源推上 prod repo，源文件 grep 确认是新的，但 docker build 出来的镜像里还是旧 bundle。根因：",[14,1043,1044],{},"--no-cache-filter"," 不可靠，BuildKit 的 build context 层仍可能命中缓存。修法：clawpanel 源改动后，必须用整体 ",[14,1047,1048],{},"--no-cache","，不要用 ",[14,1051,1044],{},"。代价：单次 ~20-30 分钟。但这是唯一能确保改动真编进去的方式。",[10,1054,1055,1056,1059],{},"这不是\"高级技巧\"或\"经验之谈\"。这是",[46,1057,1058],{},"发版的必读须知","，写进 skill 里才能确保每次都不遗漏。",[19,1061,1062],{"id":1062},"为什么这样做有效",[10,1064,1065],{},"高风险操作的安全性来自流程的确定性，不来自执行者的谨慎。人会忘、会打错、会跳步。但固化的流程不会：",[151,1067,1068,1071,1074],{},[154,1069,1070],{},"触发词明确化杀死了\"我不是故意调用发版\"的那类误操作。",[154,1072,1073],{},"步骤验证链条把每一步的前置条件和验收标准写成 Bash 检查，没法绕过。",[154,1075,1076],{},"故障处置内联把一个多月里积累的坑提前封死在流程里，新一轮发版不会重蹈覆辙。",[10,1078,1079],{},"5 个 skill 现在覆盖了：Wakou panel OTA、Connector 桌面端 OTA、OpenClaw runtime 容器全量发布、Codex 助手静默更新、加上客服远程调试的风险边界。它们没有一个是让 AI 执行发版的——都是让 AI 陪着用户，一步步走完一个铺满护栏的流程。",[10,1081,1082],{},"这是把\"不要让 AI 碰发版\"这条禁区，变成了\"AI 执行发版的框架，确保每一步都验证、每个坑都已知、每种故障都有救法\"的可行路径。",[578,1084,1085],{},"html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":38,"searchDepth":280,"depth":280,"links":1087},[1088,1089,1090,1091],{"id":622,"depth":280,"text":623},{"id":665,"depth":280,"text":666},{"id":850,"depth":280,"text":851},{"id":1062,"depth":280,"text":1062},"2026-06-16",{},"\u002F2026-06-16-skill",{"title":614,"description":619},"2026-06-16-把高风险发版固化成skill","发版是明确不交给 AI 自主执行的操作。通过触发词精确化、步骤验证链条、故障处置内联，把流程固化到 skill，让高风险操作失败概率趋零。",[609,605,1099,1100,1101],"自动化","Tauri","Docker Swarm","WbjJuf61wSEx4d5Iov2KhPdG1N6hik51RAl8rAGgug8",{"id":1104,"title":1105,"body":1106,"column":594,"date":1443,"description":1110,"extension":597,"hero_image":598,"meta":1444,"navigation":289,"path":1445,"seo":1446,"series_id":598,"severity":598,"stem":1447,"summary":1448,"tags":1449,"__hash__":1454},"posts\u002F2026-06-15-Connector两次发版复盘.md","两天连发两版，第二版是为了补第一版",{"type":7,"value":1107,"toc":1433},[1108,1111,1115,1118,1125,1136,1139,1165,1175,1179,1182,1187,1194,1209,1220,1225,1228,1243,1254,1259,1266,1269,1274,1289,1292,1296,1303,1306,1317,1321,1324,1327,1330,1335,1338,1343,1354,1359,1362,1366,1373,1379,1382,1401,1404,1410,1413,1420,1430],[10,1109,1110],{},"桌面端连接组件在一周内连发两版。1.0.10 修了 WebSocket 端点未被消费导致的 17 分钟周期断线；1.0.11 处理紧随其后暴露的僵尸令牌问题与发版脚本漏洞。这两次发版既是问题的递进修复，也是工程实践上从被动补救走向主动设计的转折。",[19,1112,1114],{"id":1113},"_101017-分钟的周期","1.0.10：17 分钟的周期",[10,1116,1117],{},"用户反馈从五月中旬开始稳定出现：Connector 约每 17 分钟自动断线一次，断后立即重连。现象本身明确，但根因指向了一个被忽视的设计缺陷。",[10,1119,1120,1121,1124],{},"服务端在五月下旬已切换 WebSocket 入口地址。原来的端点经 CDN 反代，该 CDN 的 idle 超时配置在 4-9 分钟，时间不固定。一旦断开，客户端用相同的令牌重连，再次击中 idle 超时，形成周期。这本该不是问题——服务端在 ",[14,1122,1123],{},"\u002Fauto-pair"," 响应里已返回新的 WebSocket 地址，客户端只需消费这个字段切换端点即可。",[10,1126,1127,1128,1131,1132,1135],{},"但客户端代码从未读取过这个字段。ws_runner 始终硬拼 base_url 生成连接地址，App.tsx 只提取了 ",[14,1129,1130],{},"device_token","，对 ",[14,1133,1134],{},"ws_endpoint"," 视而不见。结果是服务端返了新地址，客户端装作没看见，继续走旧路径。旧路径经 CDN，CDN idle 触发，17 分钟周期形成。",[10,1137,1138],{},"修复需要四处联动：",[151,1140,1141,1147,1156,1159],{},[154,1142,1143,1144,1146],{},"在 AppState 加一个 ",[14,1145,1134],{}," 字段，用 RwLock 包装以便运行时读取",[154,1148,1149,1152,1153,1155],{},[14,1150,1151],{},"set_device_token"," 命令新增参数，收到服务端返的 ",[14,1154,1134],{}," 时校验并持久化到 config.json",[154,1157,1158],{},"ws_runner 启动与每次重连前都从 RwLock 读取最新地址，不再拼装",[154,1160,1161,1162,1164],{},"App.tsx 消费 ",[14,1163,1134],{}," 字段，传给 invoke",[10,1166,1167,1168,1171,1172,1174],{},"兼容性处理也做了：env 变量 ",[14,1169,1170],{},"CPA_WS_ENDPOINT"," 可强制指定（灰度用），服务端没返 ",[14,1173,1134],{}," 时退化到旧路径拼装，config.json 校验失败时保留现有值不覆盖。",[137,1176,1178],{"id":1177},"_1010-发版中的五个坑","1.0.10 发版中的五个坑",[10,1180,1181],{},"代码改好了，但发版流程成了另一个故事。",[10,1183,1184],{},[46,1185,1186],{},"坑 1：假 1.0.10",[10,1188,1189,1190,1193],{},"发版脚本在 Windows 构建机上只 scp 了 tauri.conf.json 这一个文件，没有同步整个仓源码。构建机的 git HEAD 还停在 1.0.8 的 commit，Cargo.toml workspace.package.version 还是 ",[14,1191,1192],{},"0.1.0","。结果是 cargo build 出来的二进制是 1.0.8 代码，但 NSIS 打包时用了 tauri.conf.json 里的\"1.0.10\"字符串当文件名——实际上是 1.0.8 代码套上 1.0.10 的皮。",[10,1195,1196,1197,1200,1201,1204,1205,1208],{},"应急是用 ",[14,1198,1199],{},"git ls-files | tar over ssh"," 把真 1.0.10 源码同步过去，清掉 macOS metadata 的 ",[14,1202,1203],{},"._*"," 文件（tar 直接打了这些，Windows 上 tauri-build 把 ",[14,1206,1207],{},"capabilities\u002F._default.json"," 当 JSON 解析失败），重新 build。",[10,1210,1211,1212,1215,1216,1219],{},"1.0.11 的发版脚本改成了完整源码同步加 ",[14,1213,1214],{},"tar --exclude='._*'"," 和 Win 端 ",[14,1217,1218],{},"Remove-Item '._*'"," 双保险。",[10,1221,1222],{},[46,1223,1224],{},"坑 2：ssh exit code 谜团",[10,1226,1227],{},"phase B 跑完整发版脚本时，step 3（Win build）完成了——NSIS bundle 和 minisign 签名都成功写出来了——但 ssh 仍然返 exit code 1，导致脚本静默退出，step 4-10 全跳过。",[10,1229,1230,1231,1234,1235,1238,1239,1242],{},"当时推测是 ",[14,1232,1233],{},"ssh ... | tail -10"," 触发 SIGPIPE，但这个推测被现场查证打脸了：tail 会完整消费 stdin，不会向上游发 SIGPIPE。真根因当时没查清楚，可能是 PowerShell 某个 cmdlet 设了非零 ",[14,1236,1237],{},"$LASTEXITCODE","，也可能是 ssh 在大量 escape sequence 输出时本身返非零，也可能是脚本框架的 ",[14,1240,1241],{},"pipefail"," 组合出了问题。",[10,1244,1245,1246,1249,1250,1253],{},"1.0.11 用了一个兜底方案：脚本监测 build 成功的标志——",[14,1247,1248],{},"Finished 1 bundle at:"," 和 ",[14,1251,1252],{},"Finished 1 updater signature at:"," 双命中才认为成功，忽略 ssh exit code。这个兜底已经把\"ssh exit code 非零\"这条问题路径变成了已知但可控的状态：即使 ssh 返错，只要 marker 出现了，就继续执行后续步骤。1.0.11 发版时 log 记录了这次抗性：「ssh exit=1 非零，但 build marker 命中，视为成功」。",[10,1255,1256],{},[46,1257,1258],{},"坑 3：gitlink 边界",[10,1260,1261,1262,1265],{},"发版脚本 step 10 尝试 ",[14,1263,1264],{},"git add cpa-connector\u002F...\u002Ftauri.conf.json","，但因为 cpa-connector 是 gitlink（160000 mode）不是真 submodule，git 会静默跳过这条跨边界的路径。结果 tauri.conf.json 没被 add 进 root 仓的 commit。",[10,1267,1268],{},"这里需要在 cpa-connector 子仓内完成一次独立 commit（包含 Cargo.toml、Cargo.lock、package.json、tauri.conf.json），然后回到 root 仓 add 整个 gitlink 指针。1.0.11 的发版脚本改成了这个流程。",[10,1270,1271],{},[46,1272,1273],{},"坑 4 和 5：参数漏传与版本号不同步",[10,1275,1276,1277,1280,1281,1284,1285,1288],{},"Task 9 重构了前端的 ",[14,1278,1279],{},"tryAutoPairOnce"," 这个 helper 时，body 从 ",[14,1282,1283],{},"{ hostname, os, connector_version }"," 意外退化成了 ",[14,1286,1287],{},"{}","，导致服务端拿不到客户端版本号。同时 package.json 里的 version 字段还停留在 1.0.0，跟 Cargo.toml 的 1.0.9 完全脱节。",[10,1290,1291],{},"这两个都算是发版前的信息丢失，1.0.10 发版后才被指出。",[137,1293,1295],{"id":1294},"_1010-的验证与回滚","1.0.10 的验证与回滚",[10,1297,1298,1299,1302],{},"1.0.10 在 5 月 27 日上午 10:34 开始滚动更新。测试机验证了 23 分钟无断线（vs 之前每 17 分钟必断），服务端日志开始出现 ",[14,1300,1301],{},"host=\u003C直连 WS 域名>"," 的访问。4 小时窗口内，新 WS 入口命中 992 次，旧入口仍有 209566 次（因为 1.0.9 客户端的 OTA 是自然滚动，不是强推）。",[10,1304,1305],{},"但随后用户报了一个投诉，初看像是 1.0.10 的问题。经过排查发现是 openclaw-runtime 1.0.23 的一个独立 schema 问题，与 1.0.10 无关。这件事触发了一个 P0，但因为根因被快速定位到另一个模块，没有对 1.0.10 造成滚动阻断。",[10,1307,1308,1309,1312,1313,1316],{},"1.0.10 本身的回滚预案是改 ",[14,1310,1311],{},"apps\u002Fapi\u002Fsrc\u002Fconnector\u002Fupdates.ts"," 的 RELEASE_CHAIN 数组，删掉 ",[14,1314,1315],{},"'1.0.10'"," 这一项，再重部署服务端。已升级的客户端不会自动回退（Tauri updater 不支持降级），但新检查的 1.0.9 用户就不会继续升级了。这一设计决策埋下了一个伏笔：一旦版本链里出现过某个版本，再想把它从升级路径上彻底抹掉就很困难，因为已升级的用户成了\"污染源\"，他们可能带着该版本的各种遗留问题继续在线。这正是分阶段发版与步进升级设计要反复考量的权衡点。",[19,1318,1320],{"id":1319},"_1011两个小时内的闭环","1.0.11：两个小时内的闭环",[10,1322,1323],{},"1.0.10 发版后的第四个小时，某个客户端用失效的令牌反复击中 WebSocket 4401 拒绝，服务端 30 分钟内记录了 5638 次 reject，来自 2 个孤儿 device，token 前缀稳定但 DB 里查不到。",[10,1325,1326],{},"这是另一个被延期的问题。客户端收到 4401（token 失效）时没有自愈逻辑，只会 exponential backoff 后用同一个失效 token 再试一次，陷入\"僵尸状态\"——在线但永远连不上 WS。与其说这是 1.0.10 的新问题，不如说是 1.0.10 的发版暴露了原本就存在的设计缺陷。",[10,1328,1329],{},"1.0.11 同步推进了三个方向的修复：",[10,1331,1332],{},[46,1333,1334],{},"服务端黑名单",[10,1336,1337],{},"cpa-api 新增一个 TokenBlacklist 类，维护一个 LRU 缓存。收到 4401（token 失效）后，把这个 token 加进黑名单，5 分钟内的重试直接返 4401，不走 DB 查询。这样做的好处是降低 DB 压力，坏处是增加内存占用，但 5 分钟的 TTL 和 LRU 限容使得这个成本可控。",[10,1339,1340],{},[46,1341,1342],{},"客户端自愈",[10,1344,1345,1346,1349,1350,1353],{},"改造 ws.rs 的错误类型，让 4401 close code 能被单独识别为 ",[14,1347,1348],{},"WsError::TokenInvalid","。ws_runner 收到这个错误时，立即清空 in-memory 的 device_token（Mutex 设为 None），emit 一个 ",[14,1351,1352],{},"connector:\u002F\u002Ftoken-invalid"," 事件，不 sleep 直接进入下一轮 loop。frontend 端 listen 这个事件，触发 auto-pair 重新获取 token。这样一来，一次失效就能在 5-15 秒内自愈，不会陷入无限重试。",[10,1355,1356],{},[46,1357,1358],{},"发版脚本全自动化",[10,1360,1361],{},"1.0.10 里 step 4-10 需要手工兜底。1.0.11 把 step 1（版本 bump）改成了在子仓内完成 commit（idempotent skip 检查保证重跑不会失败），step 3 加了 build marker 检查，step 4 也用同样的 exit code 捕获逻辑。最后 step 1-10 全部自动完成。",[137,1363,1365],{"id":1364},"_1011-的数据","1.0.11 的数据",[10,1367,1368,1369,1372],{},"1.0.11 在 5 月 27 日下午 14:30 发版。23 分钟内，那个主要的 zombie token（前缀记录为 ",[14,1370,1371],{},"ct_UAGorMM","）的命中频率从发版前的 47\u002F分钟 降到 0.17\u002F分钟。对比是精确的：TTL 是 5 分钟，日志里看到的恰好是 5 分钟周期的 4 次 first-hit——14:35、14:40、14:45、14:50，说明黑名单在按秒级别的精度工作。",[10,1374,1375,1378],{},[14,1376,1377],{},"invalid or revoked token"," reject 日志整体从 53\u002F分钟 降到 3\u002F分钟，94% 的噪声被消除。",[10,1380,1381],{},"从发版开始到完成的总耗时是 80 分钟（spec 起草 → 发版完成），远快于 1.0.10 的 12 小时——主要是因为没有了\"假版本\"和手工兜底这两个坑。",[10,1383,1384,1385,1388,1389,1392,1393,1396,1397,1400],{},"客户端配置清理的验证是这样的：启动 1.0.11 时，旧配置目录（",[14,1386,1387],{},"%APPDATA%\u002Fcpa\u002Fcpa-connector\u002F","）存在且有历史 session.json，cleanup 把它 rename 成 ",[14,1390,1391],{},".bak.20260527-141556","；第二次启动时我手动造了一个 fake session.json，它又被 rename 成了不同时间戳的 ",[14,1394,1395],{},".bak","；第三次启动时老目录已经不存在，log 输出 ",[14,1398,1399],{},"DEBUG no legacy config dir to clean up","，幂等。软删除策略保留了原文件，方便后续排查。",[19,1402,1403],{"id":1403},"发版复盘该记录什么",[10,1405,1406,1407,76],{},"这两次发版的共同教训是：",[46,1408,1409],{},"预期效果与实际效果的差异往往来自非功能层面，发版前遗漏的验证会以人工兜底的成本在发版中浮现",[10,1411,1412],{},"1.0.10 的预期是一次提交、一次 build、一个发版脚本的无缝运行。实际遇到了\"代码没同步\"、\"metadata 污染\"、\"git 跨边界\"、\"参数漏传\"这四个层次递进的问题，每一个都需要在发版当时即时判断、即时应急、即时修复。如果这些问题在代码审查或发版前的干运行中被抓到，成本会低一个数量级。",[10,1414,1415,1416,1419],{},"1.0.11 的收益是对 1.0.10 的这些漏洞做了结构化的修复，但更重要的是建立了",[46,1417,1418],{},"验收指标清单","。版本号同步检查、发版脚本的每一步都有 success marker、gitlink 操作必须在子仓内完成、参数完整性的代码审查项。这不是一次性的修补，而是对\"发版这件事\"的流程重塑。",[10,1421,1422,1423,1426,1427,76],{},"回滚判据也值得明确。1.0.10 的触发条件是\"ws rejected 反升或 connector 启动失败率 > 0.1%\"，对应的回滚操作是修改 RELEASE_CHAIN 截断升级路径。但正如前面提到的，这个方案有一个根本限制：",[46,1424,1425],{},"已升级的用户成了污染源，无法通过服务端操作让他们回退","。这意味着一旦发出去的版本有不可接受的 bug，修复也必须通过新版本修补，不能指望用户自动回到前一个版本。这推导出一个硬性要求：",[46,1428,1429],{},"发版前的验证必须足够彻底，因为回滚的代价极高",[10,1431,1432],{},"1.0.10 和 1.0.11 的连续发版正好说明了这一点。1.0.10 解决了一个明确的功能问题，但在过程中埋了四个流程坑；1.0.11 不光修了功能缺陷（4401 自愈），也补上了流程漏洞（发版脚本全自动）。如果 1.0.10 能在发版前避免那些坑，1.0.11 就没必要冲这么紧。如果不能，1.0.11 这一次的\"加固\"就成了对下一轮发版的保险——每一次发版都可能遗留新的坑，但流程上的防御等级在递增。",{"title":38,"searchDepth":280,"depth":280,"links":1434},[1435,1439,1442],{"id":1113,"depth":280,"text":1114,"children":1436},[1437,1438],{"id":1177,"depth":286,"text":1178},{"id":1294,"depth":286,"text":1295},{"id":1319,"depth":280,"text":1320,"children":1440},[1441],{"id":1364,"depth":286,"text":1365},{"id":1403,"depth":280,"text":1403},"2026-06-15",{},"\u002F2026-06-15-connector",{"title":1105,"description":1110},"2026-06-15-Connector两次发版复盘","两天内连发两版解决 WS 端点变更与僵尸令牌问题，从手动兜底到全自动发版的演进。",[1450,1451,1452,1100,1453],"Connector","WebSocket","发版","Rust","z88KhEQF4il5fu5KEpSAcE6LACpC1v_CsLysuzrCk3Q",{"id":4,"title":5,"body":1456,"column":594,"date":595,"description":596,"extension":597,"hero_image":598,"meta":1850,"navigation":289,"path":600,"seo":1851,"series_id":598,"severity":598,"stem":602,"summary":603,"tags":1852,"__hash__":610},{"type":7,"value":1457,"toc":1837},[1458,1462,1464,1468,1473,1475,1479,1481,1483,1488,1490,1495,1499,1501,1503,1507,1509,1511,1516,1520,1522,1524,1528,1530,1532,1534,1536,1538,1540,1544,1550,1554,1562,1566,1574,1576,1578,1582,1590,1594,1600,1604,1612,1616,1620,1622,1624,1626,1650,1652,1654,1659,1663,1665,1667,1707,1709,1789,1793,1803,1805,1811,1815,1831,1835],[10,1459,12,1460,17],{},[14,1461,16],{},[19,1463,21],{"id":21},[10,1465,24,1466,28],{},[14,1467,27],{},[30,1469,1471],{"className":1470,"code":34,"language":35},[33],[14,1472,34],{"__ignoreMap":38},[10,1474,41],{},[10,1476,44,1477,49],{},[46,1478,48],{},[19,1480,52],{"id":52},[10,1482,55],{},[30,1484,1486],{"className":1485,"code":59,"language":35},[33],[14,1487,59],{"__ignoreMap":38},[10,1489,64],{},[30,1491,1493],{"className":1492,"code":68,"language":35},[33],[14,1494,68],{"__ignoreMap":38},[10,1496,73,1497,76],{},[14,1498,16],{},[19,1500,80],{"id":79},[10,1502,83],{},[10,1504,86,1505,89],{},[14,1506,16],{},[19,1508,93],{"id":92},[10,1510,96],{},[30,1512,1514],{"className":1513,"code":100,"language":35},[33],[14,1515,100],{"__ignoreMap":38},[10,1517,105,1518,109],{},[46,1519,108],{},[10,1521,112],{},[19,1523,116],{"id":115},[10,1525,119,1526,123],{},[14,1527,122],{},[10,1529,126],{},[10,1531,129],{},[19,1533,132],{"id":132},[10,1535,135],{},[137,1537,140],{"id":139},[10,1539,143],{},[10,1541,1542,149],{},[46,1543,148],{},[151,1545,1546,1548],{},[154,1547,156],{},[154,1549,159],{},[10,1551,1552,149],{},[46,1553,164],{},[151,1555,1556,1558,1560],{},[154,1557,169],{},[154,1559,172],{},[154,1561,175],{},[10,1563,1564,149],{},[46,1565,180],{},[151,1567,1568,1570,1572],{},[154,1569,185],{},[154,1571,188],{},[154,1573,191],{},[137,1575,195],{"id":194},[10,1577,198],{},[10,1579,1580,149],{},[46,1581,148],{},[151,1583,1584,1586,1588],{},[154,1585,207],{},[154,1587,210],{},[154,1589,213],{},[10,1591,1592,149],{},[46,1593,164],{},[151,1595,1596,1598],{},[154,1597,222],{},[154,1599,225],{},[10,1601,1602,149],{},[46,1603,180],{},[151,1605,1606,1608,1610],{},[154,1607,234],{},[154,1609,237],{},[154,1611,240],{},[10,1613,1614,246],{},[46,1615,245],{},[10,1617,249,1618,253],{},[14,1619,252],{},[19,1621,256],{"id":256},[10,1623,259],{},[10,1625,262],{},[30,1627,1628],{"className":265,"code":266,"language":267,"meta":38,"style":38},[14,1629,1630,1634,1638,1642,1646],{"__ignoreMap":38},[271,1631,1632],{"class":273,"line":274},[271,1633,277],{},[271,1635,1636],{"class":273,"line":280},[271,1637,283],{},[271,1639,1640],{"class":273,"line":286},[271,1641,290],{"emptyLinePlaceholder":289},[271,1643,1644],{"class":273,"line":293},[271,1645,296],{},[271,1647,1648],{"class":273,"line":299},[271,1649,302],{},[10,1651,305],{},[10,1653,308],{},[30,1655,1657],{"className":1656,"code":312,"language":35},[33],[14,1658,312],{"__ignoreMap":38},[10,1660,317,1661,320],{},[14,1662,252],{},[19,1664,323],{"id":323},[10,1666,326],{},[30,1668,1669],{"className":329,"code":330,"language":331,"meta":38,"style":38},[14,1670,1671,1681,1693,1699],{"__ignoreMap":38},[271,1672,1673,1675,1677,1679],{"class":273,"line":274},[271,1674,339],{"class":338},[271,1676,343],{"class":342},[271,1678,347],{"class":346},[271,1680,351],{"class":350},[271,1682,1683,1685,1687,1689,1691],{"class":273,"line":280},[271,1684,356],{"class":338},[271,1686,359],{"class":346},[271,1688,362],{"class":346},[271,1690,365],{"class":346},[271,1692,351],{"class":368},[271,1694,1695,1697],{"class":273,"line":286},[271,1696,373],{"class":338},[271,1698,376],{"class":368},[271,1700,1701,1703,1705],{"class":273,"line":293},[271,1702,381],{"class":346},[271,1704,384],{"class":346},[271,1706,387],{"class":368},[10,1708,390],{},[30,1710,1711],{"className":329,"code":393,"language":331,"meta":38,"style":38},[14,1712,1713,1717,1739,1743,1747,1761,1765,1769],{"__ignoreMap":38},[271,1714,1715],{"class":273,"line":274},[271,1716,401],{"class":400},[271,1718,1719,1721,1723,1725,1727,1729,1731,1733,1735,1737],{"class":273,"line":280},[271,1720,406],{"class":350},[271,1722,409],{"class":368},[271,1724,412],{"class":346},[271,1726,415],{"class":346},[271,1728,418],{"class":338},[271,1730,421],{"class":346},[271,1732,424],{"class":338},[271,1734,427],{"class":346},[271,1736,430],{"class":368},[271,1738,433],{"class":346},[271,1740,1741],{"class":273,"line":286},[271,1742,290],{"emptyLinePlaceholder":289},[271,1744,1745],{"class":273,"line":293},[271,1746,442],{"class":400},[271,1748,1749,1751,1753,1755,1757,1759],{"class":273,"line":299},[271,1750,331],{"class":350},[271,1752,449],{"class":346},[271,1754,452],{"class":342},[271,1756,455],{"class":346},[271,1758,458],{"class":338},[271,1760,461],{"class":342},[271,1762,1763],{"class":273,"line":464},[271,1764,290],{"emptyLinePlaceholder":289},[271,1766,1767],{"class":273,"line":469},[271,1768,472],{"class":400},[271,1770,1771,1773,1775,1777,1779,1781,1783,1785,1787],{"class":273,"line":475},[271,1772,478],{"class":350},[271,1774,481],{"class":368},[271,1776,484],{"class":368},[271,1778,487],{"class":346},[271,1780,490],{"class":342},[271,1782,493],{"class":350},[271,1784,496],{"class":368},[271,1786,499],{"class":346},[271,1788,502],{"class":346},[10,1790,505,1791,509],{},[14,1792,508],{},[511,1794,1795,1797,1799],{},[154,1796,515],{},[154,1798,518],{},[154,1800,521,1801],{},[14,1802,524],{},[10,1804,527],{},[10,1806,530,1807,534,1809,538],{},[14,1808,533],{},[14,1810,537],{},[10,1812,541,1813,545],{},[14,1814,544],{},[151,1816,1817,1821,1825,1827],{},[154,1818,1819,553],{},[14,1820,552],{},[154,1822,556,1823,560],{},[14,1824,559],{},[154,1826,563],{},[154,1828,566,1829,570],{},[14,1830,569],{},[10,1832,573,1833,576],{},[14,1834,544],{},[578,1836,580],{},{"title":38,"searchDepth":280,"depth":280,"links":1838},[1839,1840,1841,1842,1843,1844,1848,1849],{"id":21,"depth":280,"text":21},{"id":52,"depth":280,"text":52},{"id":79,"depth":280,"text":80},{"id":92,"depth":280,"text":93},{"id":115,"depth":280,"text":116},{"id":132,"depth":280,"text":132,"children":1845},[1846,1847],{"id":139,"depth":286,"text":140},{"id":194,"depth":286,"text":195},{"id":256,"depth":280,"text":256},{"id":323,"depth":280,"text":323},{},{"title":5,"description":596},[605,606,607,608,609],1785406912447]