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