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