[{"data":1,"prerenderedAt":2355},["ShallowReactive",2],{"\u002F2026-05-27":3,"\u002F2026-05-27-rel":611},{"id":4,"title":5,"body":6,"column":594,"date":595,"description":596,"extension":597,"hero_image":598,"meta":599,"navigation":289,"path":600,"seo":601,"series_id":598,"severity":598,"stem":602,"summary":603,"tags":604,"__hash__":610},"posts\u002F2026-05-27-步进升级为什么增量包不能跨版本跳.md","跳过一个撤回的版本，客户就少了个 DLL",{"type":7,"value":8,"toc":581},"minimark",[9,18,22,29,39,42,50,53,56,62,65,71,77,81,84,90,94,97,103,110,113,117,124,127,130,133,136,141,144,150,160,165,176,181,192,196,199,203,214,218,226,230,241,247,254,257,260,263,303,306,309,315,321,324,327,388,391,503,510,525,528,539,546,571,577],[10,11,12,13,17],"p",{},"1.0.7 客户升级到 1.0.9 后启动崩溃，报 ",[14,15,16],"code",{},"STATUS_DLL_NOT_FOUND","。根因在于增量包的构造逻辑与发版序列的交互：任何\"中间 GA 版本被撤回且后续版本以它作为 baseline\"的组合，都会让跳过撤回版本的客户拿到不完整的增量包。",[19,20,21],"h2",{"id":21},"增量包的设计前提",[10,23,24,25,28],{},"OTA 更新包是二进制差分产物——",[14,26,27],{},"release.sh"," 在构造增量包时，只列入「FROM → TO 两个相邻版本之间 sha256 实际不同的文件」。以 1.0.8 → 1.0.9 的增量为例：",[30,31,36],"pre",{"className":32,"code":34,"language":35},[33],"language-text","1.0.8 dist ↔ 1.0.9 dist\n  vcruntime140.dll (sha256: abc...)  vcruntime140.dll (sha256: abc...)\n                                    ↑ 一致，不进差分包\n  msvcp140.dll (sha256: def...)     msvcp140.dll (sha256: def...)\n                                    ↑ 一致，不进差分包\n  launcher.exe (sha256: old)        launcher.exe (sha256: new)\n                                    ↑ 不同，进差分包\n","text",[14,37,34],{"__ignoreMap":38},"",[10,40,41],{},"相比全量包（500+ MB），增量包通常只有 100-200 MB。对于国内弱网、公网、海外长途网络来说，包体积直接影响下载时间和失败率。选择差分包的代价是必须确保客户的升级链路完整。",[10,43,44,45,49],{},"这个设计基于一个隐含的假设：",[46,47,48],"strong",{},"客户严格按发布顺序逐版升级","（1.0.7 → 1.0.8 → 1.0.9 → ...）。每跳一个版本，就叠加一次「FROM→TO」的相邻增量。所有累积变更都会被依次应用，最终到达完整的目标版本。只要这个假设成立，差分机制就是 sound 的。但一旦中间版本被 recall 且升序逻辑允许跳过，假设就被破坏了。",[19,51,52],{"id":52},"发版序列与撤回的交互",[10,54,55],{},"关键的发版序列是这样的：",[30,57,60],{"className":58,"code":59,"language":35},[33],"1.0.5  GA   无 VCRuntime DLL\n1.0.6  recalled\n1.0.7  GA   无 VCRuntime DLL\n1.0.8  GA   ↓ 首次 Copy-VcRuntime，dist 含 9 个 VC++ Runtime DLL\n       后 recalled（因 OTA_TRASH_CORRUPT 现场卡死）\n1.0.9  GA   继承 1.0.8 build，含 VCRuntime DLL\n       baseline = 1.0.8（diff 算相邻两版）\n       minClient = 1.0.8（改为 1.0.7 后触发此 bug）\n",[14,61,59],{"__ignoreMap":38},[10,63,64],{},"当 1.0.9 的 minClient 被改为 1.0.7 后，升序逻辑会为 1.0.7 客户返回 1.0.9 的 manifest。minClient 是一个关键的版本约束字段——它表示\"升级到当前版本前，客户必须至少是什么版本\"。修改 minClient 的动机通常是想扩大升级适配范围（比如从 1.0.8 放宽到 1.0.7），但这个改动隐含了一个假设：\"当前版本及其所有依赖都能兼容从 minClient 开始的所有中间版本\"。在这个案例中，这个假设被打破了。",[30,66,69],{"className":67,"code":68,"language":35},[33],"G26 升序查询（1.0.7 客户，seek current 之后最小 GA）：\n  候选 1.0.8 → recalled，跳过\n  候选 1.0.9 → GA，minClient=1.0.7≤1.0.7 满足 → 命中，返回 1.0.9 manifest\n",[14,70,68],{"__ignoreMap":38},[10,72,73,74,76],{},"1.0.7 客户 apply 1.0.9 的差分包后，系统启动时 launcher.exe 试图加载 VCRUNTIME140.dll，但客户机的 dist 里没有这个文件——1.0.8 引入的 9 个 DLL 永远没有被应用过。Windows 加载器在 LoadLibrary 阶段直接终结进程，报 ",[14,75,16],{},"。",[19,78,80],{"id":79},"为什么-107-看起来能启动升级后就崩溃","为什么 1.0.7 看起来能启动，升级后就崩溃",[10,82,83],{},"这里需要澄清一个关键点。1.0.7 的 dist 里确实没有 VCRuntime DLL，但 1.0.7 版本的应用仍然能启动。原因在于 launcher.exe 是用 1.0.7 时代的 toolchain build 的，它依赖的 MSVC CRT 版本恰好在干净 Windows 的 System32 里能找到（或者该客户机预装了对应版本的 Visual C++ Redistributable）。这是一个\"侥幸\"——1.0.7 构建时选用的工具链版本较旧，CRT 的 ABI 足够稳定，系统库能满足。",[10,85,86,87,89],{},"一旦升级到 1.0.9，launcher.exe 是用更新的 toolchain build 的，它依赖的 CRT 版本更新了，链接的 API 集合也有变化。客户机的 System32 里没有这个新版 CRT。Windows PE 加载器会在 LoadLibrary 阶段就找不到 VCRUNTIME140.dll，进程在主函数之前被系统杀掉（",[14,88,16],{},"）。应用的错误处理机制根本没机会启动——没有 try-catch，没有日志，只有系统错误弹窗和退出码。",[19,91,93],{"id":92},"为什么会漏-dll","为什么会漏 DLL",[10,95,96],{},"这是差分包设计与版本跳过的碰撞。1.0.9 的差分包不包含 vcruntime140.dll，原因看似无害：",[30,98,101],{"className":99,"code":100,"language":35},[33],"1.0.8 dist                1.0.9 dist\n  vcruntime140.dll          vcruntime140.dll\n  sha256: \u003Chash>            sha256: \u003Chash>（同一个 build 产物，哈希相同）\n  → 两边都有，且相同 → 不进差分包\n",[14,102,100],{"__ignoreMap":38},[10,104,105,106,109],{},"但这个逻辑基于一个前提：",[46,107,108],{},"从 1.0.8 升级来","。如果客户是从 1.0.7 升级，那么它的 dist 里根本没有 vcruntime140.dll，差分包也不会帮它补上——因为 1.0.9 diff 算法看的是\"1.0.8 有且 1.0.9 也有\" 的文件。",[10,111,112],{},"任何「中间 GA 版本引入新文件 + 该版本被 recall + 后续版本用它作 baseline」的组合，都会造成同样的问题。这不是 DLL 特有的现象，而是增量包机制本身的约束。",[19,114,116],{"id":115},"为什么-108-会引入-dll","为什么 1.0.8 会引入 DLL",[10,118,119,120,123],{},"顺便说一下 1.0.8 为什么成为\"罪魁祸首\"。1.0.8 是第一个在 build-portable.ps1 中加入 ",[14,121,122],{},"Copy-VcRuntime"," 的版本，它主动把整套 MSVC redist DLL（9 个）旁置到 dist，这样便携包就不再依赖系统级安装的 Visual C++ Redistributable。这个改动本身是对的，但由于 1.0.8 随后因 OTA_TRASH_CORRUPT 问题被 recall，它成了\"引入新文件但被撤回\"的典型。",[10,125,126],{},"1.0.9 继承了 1.0.8 的 build，自然也含有这 9 个 DLL，但 1.0.9 的差分包相对 1.0.8 计算，所以这些 DLL 不会再被列入。结果就是：1.0.7 客户跳过 1.0.8，直接升到 1.0.9，永远收不到这些 DLL。",[10,128,129],{},"同时，还有一个独立的关联问题：修复脚本 Fix-VCRuntime.bat 本身因为编码问题（UTF-8 vs GBK 的冲突）无法正常执行，所以即使用户尝试手动修复，脚本也会报错。这是打包流程和脚本编码的两个独立根因，在同一时间窗口内暴露了出来。",[19,131,132],{"id":132},"修复方案的权衡",[10,134,135],{},"面对这个问题，有两条主要路径可选：",[137,138,140],"h3",{"id":139},"方案-a全量兜底","方案 A：全量兜底",[10,142,143],{},"跨版本升级时直接下发完整 full.zip，而不是差分包。",[10,145,146,149],{},[46,147,148],{},"优点","：",[151,152,153,157],"ul",{},[154,155,156],"li",{},"简单粗暴，一个包包含所有文件，不存在漏 DLL 的可能",[154,158,159],{},"不需要调整发版流程",[10,161,162,149],{},[46,163,164],{},"缺点",[151,166,167,170,173],{},[154,168,169],{},"包体积大（差分包通常 100-200 MB，全量 500+ MB）",[154,171,172],{},"客户网络环境差时升级时间长，失败率高",[154,174,175],{},"CDN 带宽成本增加",[10,177,178,149],{},[46,179,180],{},"适用场景",[151,182,183,186,189],{},[154,184,185],{},"一次性应急（发 hotfix 回滚）",[154,187,188],{},"用户基数小、网络条件好",[154,190,191],{},"跨度非常大（比如 1.0.1 → 2.0.0）的少见场景",[137,193,195],{"id":194},"方案-b严格步进","方案 B：严格步进",[10,197,198],{},"服务端按发布链下发下一个版本，不允许跨级。即使是 recalled 版本也保留在链上，只是不作为升级终点。",[10,200,201,149],{},[46,202,148],{},[151,204,205,208,211],{},[154,206,207],{},"包体积小，增量包通常 100-200 MB",[154,209,210],{},"每个客户走相同的升级链路，问题复现容易排查",[154,212,213],{},"充分利用增量包设计的初衷",[10,215,216,149],{},[46,217,164],{},[151,219,220,223],{},[154,221,222],{},"升级链路长（1.0.7 → 1.0.9 时需要先升 1.0.8，即使 1.0.8 被 recalled）",[154,224,225],{},"如果某个中间版本有严重问题（比如 OTA 卡死），客户升级时仍会卡在它上面",[10,227,228,149],{},[46,229,180],{},[151,231,232,235,238],{},[154,233,234],{},"绝大多数常规发版",[154,236,237],{},"网络条件多变的客户群",[154,239,240],{},"需要严格控制升级质量的生产环境",[10,242,243,246],{},[46,244,245],{},"为什么选择 B","：有几个关键因素。首先，客户群分布广（企业内网、弱网、移动网络混合），300+ MB 的全量包在弱网场景下升级失败率会明显增加，反而增加了支持成本。其次，一旦引入全量兜底，就容易形成\"跨版本时总是下发全量\"的惯性，长期来看放弃了增量包的设计初衷。最后，严格步进虽然升级链路长，但每个版本的 DL + apply 成本都是可预测的，失败时也容易重试。",[10,248,249,250,253],{},"通过让 recalled 版本保持在链上（只改为 non-GA 状态），升序逻辑会自动跳过它们，下一个 GA 版本会包含完整的累积增量。1.0.9 被 recall 后，G26 升序对 1.0.7 客户会自动推荐 1.0.10；1.0.10 的差分是相对 1.0.7 算的（通过 ",[14,251,252],{},"FROM_VERSION=1.0.7"," 显式指定），包含了 1.0.8 引入的 9 个 DLL 以及后续的所有变更。这样既保持了包体积小的优势，又确保了跨越 recalled 版本的客户能拿到完整增量。1.0.10 的差分包最关键的是 Removes 为 0——任何跨版本的客户 apply 都不会留下废文件。",[19,255,256],{"id":256},"为什么撤回版本不能从链上摘掉",[10,258,259],{},"这里有一个容易忽视的细节：即使某个版本被 recall，也不能从版本链中物理删除。",[10,261,262],{},"如果把 1.0.8 从数据库里删掉，升序逻辑会直接跳过它，导致 1.0.7 客户被推荐到 1.0.9——此时就回到了原来的问题。修复的关键是让升序逻辑仍然\"看到\" 1.0.8 的存在（用于确定差分链路），但把它的 rollout 状态改为 non-GA，使得它永远不会被作为升级终点。",[30,264,268],{"className":265,"code":266,"language":267,"meta":38,"style":38},"language-sql shiki shiki-themes github-light github-dark","-- 这样的做法是错的（会复现问题）：\nDELETE FROM releases WHERE version = '1.0.8';\n\n-- 正确的做法：\nUPDATE releases SET rollout_status = 'recalled' WHERE version = '1.0.8';\n","sql",[14,269,270,278,284,291,297],{"__ignoreMap":38},[271,272,275],"span",{"class":273,"line":274},"line",1,[271,276,277],{},"-- 这样的做法是错的（会复现问题）：\n",[271,279,281],{"class":273,"line":280},2,[271,282,283],{},"DELETE FROM releases WHERE version = '1.0.8';\n",[271,285,287],{"class":273,"line":286},3,[271,288,290],{"emptyLinePlaceholder":289},true,"\n",[271,292,294],{"class":273,"line":293},4,[271,295,296],{},"-- 正确的做法：\n",[271,298,300],{"class":273,"line":299},5,[271,301,302],{},"UPDATE releases SET rollout_status = 'recalled' WHERE version = '1.0.8';\n",[10,304,305],{},"换句话说，版本链的完整性是优于减少数据库记录的。每次新版本发布时，baseline 计算和 diff 生成都要以完整的版本序列为基础。所以即使 1.0.8 和 1.0.9 都被 recall，它们仍然需要在数据库中占据一个位置，作为\"这个版本存在过，但用户不应该升级到它\"的标记。升序逻辑看到 recalled 标记后会直接跳过，继续往后找第一个 GA 版本。",[10,307,308],{},"修复后的数据库状态是：",[30,310,313],{"className":311,"code":312,"language":35},[33],"1.0.7   GA         minClient=1.0.5\n1.0.8   recalled\n1.0.9   recalled   ← 这一步至关重要\n1.0.10  GA         minClient=1.0.7\n",[14,314,312],{"__ignoreMap":38},[10,316,317,318,320],{},"现在 1.0.7 客户升序时会依次检查 1.0.8（recalled，跳）→ 1.0.9（recalled，跳）→ 1.0.10（GA，命中）。1.0.10 的差分包是通过 ",[14,319,252],{}," 显式指定的，所以它包含 1.0.8 和 1.0.9 引入的所有文件。",[19,322,323],{"id":323},"预防和后续",[10,325,326],{},"立即修复是发 1.0.10 并 recall 1.0.9，已在 2026-05-11 19:42 完成。发布 1.0.10 时的关键操作是显式指定 baseline：",[30,328,332],{"className":329,"code":330,"language":331,"meta":38,"style":38},"language-bash shiki shiki-themes github-light github-dark","FROM_VERSION=1.0.7 \\\nMINISIGN_PASSWORD='...' ADMIN_PASS='...' BUILD_HOST_PASSWORD='...' \\\nROLLOUT_STATUS=whitelist \\\n  bash scripts\u002Frelease.sh 1.0.10\n","bash",[14,333,334,352,369,377],{"__ignoreMap":38},[271,335,336,340,344,348],{"class":273,"line":274},[271,337,339],{"class":338},"sVt8B","FROM_VERSION",[271,341,343],{"class":342},"szBVR","=",[271,345,347],{"class":346},"sZZnC","1.0.7",[271,349,351],{"class":350},"sScJk"," \\\n",[271,353,354,357,360,363,366],{"class":273,"line":280},[271,355,356],{"class":338},"MINISIGN_PASSWORD=",[271,358,359],{"class":346},"'...'",[271,361,362],{"class":346}," ADMIN_PASS='...'",[271,364,365],{"class":346}," BUILD_HOST_PASSWORD='...'",[271,367,351],{"class":368},"sj4cs",[271,370,371,374],{"class":273,"line":286},[271,372,373],{"class":338},"ROLLOUT_STATUS=whitelist ",[271,375,376],{"class":368},"\\\n",[271,378,379,382,385],{"class":273,"line":293},[271,380,381],{"class":346},"  bash",[271,383,384],{"class":346}," scripts\u002Frelease.sh",[271,386,387],{"class":368}," 1.0.10\n",[10,389,390],{},"这样 diff 生成引擎会计算 1.0.7 ↔ 1.0.10 的完整差异，而不是默认的\"上一个 GA\"（1.0.9）。然后执行 PATCH 和 recall 操作：",[30,392,394],{"className":329,"code":393,"language":331,"meta":38,"style":38},"# 1. PATCH 1.0.10 → ga\ncurl -X PATCH \"$ADMIN\u002Fadmin\u002Freleases\u002F$ID\u002Frollout\" -d '{\"status\":\"ga\"}'\n\n# 2. recall 1.0.9（防止升序仍然推荐给 1.0.7 客户）\nbash scripts\u002Frelease\u002Frecall.sh \u003Crelease-id>\n\n# 3. 清 redis rollout-wrapper cache（立即生效，不等待 cache 过期）\nredis-cli --scan --pattern 'cache:rollout-wrapper:*' | xargs -r redis-cli DEL\n",[14,395,396,402,434,438,443,462,467,473],{"__ignoreMap":38},[271,397,398],{"class":273,"line":274},[271,399,401],{"class":400},"sJ8bj","# 1. PATCH 1.0.10 → ga\n",[271,403,404,407,410,413,416,419,422,425,428,431],{"class":273,"line":280},[271,405,406],{"class":350},"curl",[271,408,409],{"class":368}," -X",[271,411,412],{"class":346}," PATCH",[271,414,415],{"class":346}," \"",[271,417,418],{"class":338},"$ADMIN",[271,420,421],{"class":346},"\u002Fadmin\u002Freleases\u002F",[271,423,424],{"class":338},"$ID",[271,426,427],{"class":346},"\u002Frollout\"",[271,429,430],{"class":368}," -d",[271,432,433],{"class":346}," '{\"status\":\"ga\"}'\n",[271,435,436],{"class":273,"line":286},[271,437,290],{"emptyLinePlaceholder":289},[271,439,440],{"class":273,"line":293},[271,441,442],{"class":400},"# 2. recall 1.0.9（防止升序仍然推荐给 1.0.7 客户）\n",[271,444,445,447,450,453,456,459],{"class":273,"line":299},[271,446,331],{"class":350},[271,448,449],{"class":346}," scripts\u002Frelease\u002Frecall.sh",[271,451,452],{"class":342}," \u003C",[271,454,455],{"class":346},"release-i",[271,457,458],{"class":338},"d",[271,460,461],{"class":342},">\n",[271,463,465],{"class":273,"line":464},6,[271,466,290],{"emptyLinePlaceholder":289},[271,468,470],{"class":273,"line":469},7,[271,471,472],{"class":400},"# 3. 清 redis rollout-wrapper cache（立即生效，不等待 cache 过期）\n",[271,474,476,479,482,485,488,491,494,497,500],{"class":273,"line":475},8,[271,477,478],{"class":350},"redis-cli",[271,480,481],{"class":368}," --scan",[271,483,484],{"class":368}," --pattern",[271,486,487],{"class":346}," 'cache:rollout-wrapper:*'",[271,489,490],{"class":342}," |",[271,492,493],{"class":350}," xargs",[271,495,496],{"class":368}," -r",[271,498,499],{"class":346}," redis-cli",[271,501,502],{"class":346}," DEL\n",[10,504,505,506,509],{},"预防层面，在 ",[14,507,508],{},"wakou-release"," skill 中加了三条新检测项（G27\u002FG28\u002FG29）：",[511,512,513,516,519],"ol",{},[154,514,515],{},"检查 releases 表里有没有「ga → recalled 但后续版本 baseline 还指着它」的组合",[154,517,518],{},"评估是否存在客户群可能跨过 recalled 版本拿增量",[154,520,521,522],{},"如果是，强制新版本 specify ",[14,523,524],{},"FROM_VERSION=\u003C最老活跃 GA 版本>",[10,526,527],{},"这三条检测在每次发版前自动跑，有问题的组合会在 release.sh 阶段被拦住，不允许走到\"上传 CDN\"这一步。",[10,529,530,531,534,535,538],{},"后续架构层改进（未实施）：admin server 的 G26 解析逻辑可以加「baseline 链路完整性检查」。具体是：当升序逻辑返回 manifest 给客户时，校验 ",[14,532,533],{},"client.version → release.baseline_version"," 之间的所有版本是否都是 GA（无 recalled）。如果链路中存在 recalled 版本，admin 直接返回错误码（比如 ",[14,536,537],{},"EXTRACT_FROM_OLDER_BASELINE","）给 OTA worker，让客户 fallback 到全量包请求，而不是无声给一个不完整的差分。",[10,540,541,542,545],{},"这个改进的收益是\"防御性更强\"——即使人肉操作出了问题（比如遗漏了 ",[14,543,544],{},"FROM_VERSION="," 指定），系统也能主动检测并避免分发坏包。但代价是需要修改：",[151,547,548,554,561,564],{},[154,549,550,553],{},[14,551,552],{},"release-manifest.json"," 的 schema（添加链路检查标记）",[154,555,556,557,560],{},"admin 的 ",[14,558,559],{},"update.ts::resolveBonjourCliPath"," 逻辑",[154,562,563],{},"OTA worker 的错误码处理（识别新的 fallback 信号）",[154,565,566,567,570],{},"CDN 需要同时提供 ",[14,568,569],{},"full-from-empty.zip","（或允许 diff 降级到 full）",[10,572,573,574,576],{},"鉴于目前的发版流程和人肉检测已经能防住这个问题，架构层改进被标记为后续单独需求，暂时先依赖流程检查和 ",[14,575,544],{}," 人肉兜底。",[578,579,580],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":38,"searchDepth":280,"depth":280,"links":582},[583,584,585,586,587,588,592,593],{"id":21,"depth":280,"text":21},{"id":52,"depth":280,"text":52},{"id":79,"depth":280,"text":80},{"id":92,"depth":280,"text":93},{"id":115,"depth":280,"text":116},{"id":132,"depth":280,"text":132,"children":589},[590,591],{"id":139,"depth":286,"text":140},{"id":194,"depth":286,"text":195},{"id":256,"depth":280,"text":256},{"id":323,"depth":280,"text":323},"交付与更新","2026-05-27","1.0.7 客户升级到 1.0.9 后启动崩溃，报 STATUS_DLL_NOT_FOUND。根因在于增量包的构造逻辑与发版序列的交互：任何\"中间 GA 版本被撤回且后续版本以它作为 baseline\"的组合，都会让跳过撤回版本的客户拿到不完整的增量包。","md",null,{},"\u002F2026-05-27",{"title":5,"description":596},"2026-05-27-步进升级为什么增量包不能跨版本跳","增量包是相邻两版的差分，跳过中间版本会漏掉某版本引入的文件，导致客户启动崩溃。",[605,606,607,608,609],"OTA","增量更新","版本管理","二进制差分","发版流程","mO5egKlEmvxpoLQ8R1GYBlnXzKl1bq1NoHYzI2X5h-0",[612,1005,1865],{"id":613,"title":614,"body":615,"column":594,"date":990,"description":991,"extension":597,"hero_image":598,"meta":992,"navigation":289,"path":993,"seo":994,"series_id":598,"severity":598,"stem":995,"summary":996,"tags":997,"__hash__":1004},"posts\u002F2026-08-16-让会话记住目录让回答能停下.md","让会话记住目录，让回答能停下",{"type":7,"value":616,"toc":981},[617,623,627,630,633,636,661,664,670,677,683,687,690,704,707,710,713,717,720,725,742,745,750,759,762,767,776,779,783,786,789,794,800,803,808,817,820,825,834,837,844,849,858,861,866,869,873,876,883,887,890,893,897,900,969,972,978],[10,618,619,620],{},"同一天做了两件事。表面上一个关于文件系统，一个关于流式生成，实际在解决同一个问题：",[46,621,622],{},"长动作要么在一开始就能定好条件，要么在过程中能叫停。",[19,624,626],{"id":625},"一工作目录原先只活在一次调用里","一、工作目录原先只活在一次调用里",[10,628,629],{},"工具调用要指定工作目录。原先的实现是每次调用各自带一个。",[10,631,632],{},"这在单次调用里没问题，但会话是连续的：用户在一个会话里说「看看这个项目」「改一下那个配置」「跑一下测试」，每次都指定一遍目录，重复且容易出错。",[10,634,635],{},"改成会话级能力。数据模型加两个字段：",[30,637,639],{"className":265,"code":638,"language":267,"meta":38,"style":38},"-- 会话级工作文件夹：null 表示沿用设备上报的默认目录，对存量会话零影响。\nALTER TABLE \"Session\" ADD COLUMN \"workspaceDir\" TEXT;\n-- 桌面端注册时上报的本机默认工作文件夹；老版本客户端不上报则保持 null。\nALTER TABLE \"Device\" ADD COLUMN \"defaultWorkspaceDir\" TEXT;\n",[14,640,641,646,651,656],{"__ignoreMap":38},[271,642,643],{"class":273,"line":274},[271,644,645],{},"-- 会话级工作文件夹：null 表示沿用设备上报的默认目录，对存量会话零影响。\n",[271,647,648],{"class":273,"line":280},[271,649,650],{},"ALTER TABLE \"Session\" ADD COLUMN \"workspaceDir\" TEXT;\n",[271,652,653],{"class":273,"line":286},[271,654,655],{},"-- 桌面端注册时上报的本机默认工作文件夹；老版本客户端不上报则保持 null。\n",[271,657,658],{"class":273,"line":293},[271,659,660],{},"ALTER TABLE \"Device\" ADD COLUMN \"defaultWorkspaceDir\" TEXT;\n",[10,662,663],{},"两个字段的分工是：设备上报它本机的默认目录，会话可以覆盖它。取值优先级：",[30,665,668],{"className":666,"code":667,"language":35},[33],"会话值 ?? 设备默认值\n",[14,669,667],{"__ignoreMap":38},[10,671,672,673,676],{},"迁移只用加列，",[14,674,675],{},"null"," 表示沿用设备默认值，存量会话不受影响。",[10,678,679,680,76],{},"长度上限 512 字符，写在协议包里：",[14,681,682],{},"WORKSPACE_DIR_MAX_LENGTH = 512",[19,684,686],{"id":685},"二四个执行入口共用同一个目录","二、四个执行入口共用同一个目录",[10,688,689],{},"目录信息要传到的位置比想象中多。同一件事在四个地方会被执行：",[151,691,692,695,698,701],{},[154,693,694],{},"工具调用",[154,696,697],{},"Agent 团队运行",[154,699,700],{},"设备协作",[154,702,703],{},"计划任务",[10,705,706],{},"如果只有工具调用拿到会话目录，其余三个各自回落到设备默认值，用户会遇到「我在会话里设了目录，跑计划任务却跑到了别的地方」。",[10,708,709],{},"所以目录跟着会话走，四个入口都从同一个地方取。",[10,711,712],{},"跨端还有一层：桌面端主进程、预加载层、网页端桥接、聊天界面。四处都要能读能写，用户才能在会话里查看或切换目录。",[19,714,716],{"id":715},"三路径校验的三个坑","三、路径校验的三个坑",[10,718,719],{},"工作目录涉及文件系统，三处安全处理值得单独写。",[10,721,722],{},[46,723,724],{},"相对路径要先解析成绝对路径。",[30,726,730],{"className":727,"code":728,"language":729,"meta":38,"style":38},"language-ts shiki shiki-themes github-light github-dark","\u002F\u002F 相对路径必须先解析成绝对路径再做高危校验，否则 ..\u002F..\u002F..\u002Fetc\u002Fpasswd\n\u002F\u002F 这类相对串匹配不上敏感路径正则会被直接放行\n","ts",[14,731,732,737],{"__ignoreMap":38},[271,733,734],{"class":273,"line":274},[271,735,736],{"class":400},"\u002F\u002F 相对路径必须先解析成绝对路径再做高危校验，否则 ..\u002F..\u002F..\u002Fetc\u002Fpasswd\n",[271,738,739],{"class":273,"line":280},[271,740,741],{"class":400},"\u002F\u002F 这类相对串匹配不上敏感路径正则会被直接放行\n",[10,743,744],{},"先做正则匹配再解析，等于给相对路径开了一条绕过通道——它们看起来不像敏感路径。",[10,746,747],{},[46,748,749],{},"打开目录前先确认它是目录。",[30,751,753],{"className":727,"code":752,"language":729,"meta":38,"style":38},"\u002F\u002F open-workspace-dir 先 stat 确认是目录才放行，避免经 shell.openPath 执行可执行文件\n",[14,754,755],{"__ignoreMap":38},[271,756,757],{"class":273,"line":274},[271,758,752],{"class":400},[10,760,761],{},"用系统调用打开路径时，如果路径指向一个可执行文件，行为会变成执行它。",[10,763,764],{},[46,765,766],{},"目录不存在时要说明，不能静默回落。",[30,768,770],{"className":727,"code":769,"language":729,"meta":38,"style":38},"\u002F\u002F 换机时会话目录在本机不存在则回落设备默认目录，并在工具结果中明示，不静默。\n",[14,771,772],{"__ignoreMap":38},[271,773,774],{"class":273,"line":274},[271,775,769],{"class":400},[10,777,778],{},"这一条和一个功能细节有关：会话可以跨设备打开。用户在公司电脑上设的目录，回家打开时本机不存在。此时回落是唯一选择，但用户必须知道发生了回落——否则他会以为工具跑在他指定的目录里。",[19,780,782],{"id":781},"四停止生成","四、停止生成",[10,784,785],{},"第二个功能是让流式回答可以中途停止。",[10,787,788],{},"场景很具体：模型开始生成一段长回答，用户看前两段就知道方向不对。原先只能等它写完，或者刷新页面重新提问。",[10,790,791],{},[46,792,793],{},"停止信号走 Redis。",[30,795,798],{"className":796,"code":797,"language":35},[33],"POST \u002Fapi\u002Fchat\u002Fstop\n",[14,799,797],{"__ignoreMap":38},[10,801,802],{},"校验会话归属之后，往 Redis 写一个停止信号。选 Redis 而不是进程内变量，是因为生成可能发生在任意一个 pod 上——信号要跨 pod 生效。同一个会话重复调用是幂等的。",[10,804,805],{},[46,806,807],{},"生成侧轮询读信号。",[30,809,811],{"className":727,"code":810,"language":729,"meta":38,"style":38},"\u002F\u002F 生成侧用节流轮询（300ms）读信号，轮边界与流式回调两处检查点；在途工具跑完再收尾\n",[14,812,813],{"__ignoreMap":38},[271,814,815],{"class":273,"line":274},[271,816,810],{"class":400},[10,818,819],{},"两个检查点：一个是每轮对话的边界，一个是流式回调。工具执行中途不打断——一个已经在跑的工具调用停下来会留下半截状态，等它跑完再收尾。",[10,821,822],{},[46,823,824],{},"停止必须落一条消息。",[30,826,828],{"className":727,"code":827,"language":729,"meta":38,"style":38},"\u002F\u002F 停止一定落一条 assistant 消息并记 stoppedAt，否则前端会一直判成生成中\n",[14,829,830],{"__ignoreMap":38},[271,831,832],{"class":273,"line":274},[271,833,827],{"class":400},[10,835,836],{},"这是被前端状态机决定的。前端的「生成中」判定依据是「最后一条是不是用户消息」，如果停止之后不落一条 assistant 消息，界面会一直显示在生成。",[10,838,839,840,843],{},"数据库加一个字段：",[14,841,842],{},"Message.stoppedAt","。落这条消息时记下停止时刻——刷新、重进会话之后状态不该丢。",[10,845,846],{},[46,847,848],{},"结算按实际用量。",[30,850,852],{"className":727,"code":851,"language":729,"meta":38,"style":38},"\u002F\u002F 结算走原有 settle 路径按实际用量多退少补；被停止的半截回答不进长期记忆\n",[14,853,854],{"__ignoreMap":38},[271,855,856],{"class":273,"line":274},[271,857,851],{"class":400},[10,859,860],{},"半截回答不入长期记忆这一条是必要的。记忆提取的输入是完整的一轮对话，半截内容提取出来的「用户偏好」会是错的。",[10,862,863],{},[46,864,865],{},"继续写。",[10,867,868],{},"停止之后，末条消息上给一个「继续写」。它不是重新生成——从停止的位置接着往下，上下文里包含已经写好的部分。",[19,870,872],{"id":871},"五界面上只有一个按钮","五、界面上只有一个按钮",[10,874,875],{},"生成中，发送按钮变成停止按钮。桌面端和移动端两套布局都要改。",[10,877,878,879,882],{},"这个交互决定了一个细节：停止是一个",[46,880,881],{},"和发送同一位置","的操作。用户在等待时手指本来就停在那个位置，改文案比加一个新按钮更快。",[19,884,886],{"id":885},"六桌面端版本","六、桌面端版本",[10,888,889],{},"这两个功能里，工作目录要桌面端配合。桌面端从 1.0.32 升到 1.0.33。",[10,891,892],{},"顺序上有依赖：云端先上线，桌面端包发出后功能才对用户可见。所以版本号更新是单独一个提交，不是为了记录，是为了标记「从哪个包开始这个功能可用」。",[19,894,896],{"id":895},"七两个功能的共同点","七、两个功能的共同点",[10,898,899],{},"把它们放在一起看，都是「让用户在长动作里保留控制权」：",[901,902,903,918],"table",{},[904,905,906],"thead",{},[907,908,909,912,915],"tr",{},[910,911],"th",{},[910,913,914],{},"工作目录",[910,916,917],{},"停止生成",[919,920,921,933,947,958],"tbody",{},[907,922,923,927,930],{},[924,925,926],"td",{},"控制发生在",[924,928,929],{},"事前指定",[924,931,932],{},"事中叫停",[907,934,935,938,943],{},[924,936,937],{},"数据落点",[924,939,940],{},[14,941,942],{},"Session.workspaceDir",[924,944,945],{},[14,946,842],{},[907,948,949,952,955],{},[924,950,951],{},"跨端要求",[924,953,954],{},"主进程 \u002F 预加载 \u002F 桥接 \u002F 界面",[924,956,957],{},"桌面与移动两套布局",[907,959,960,963,966],{},[924,961,962],{},"不静默的地方",[924,964,965],{},"目录不存在时回落要明示",[924,967,968],{},"停止后必须落消息，界面不能一直显示生成中",[10,970,971],{},"四条对应关系里，最后一行是最容易做错的：两个功能都有「状态没落对，界面就一直显示在跑」的风险。工作目录那条如果静默回落，用户以为跑在 A 目录，实际跑在 B；停止那条如果不落消息，用户以为停了，界面说还在跑。",[10,973,974,975],{},"改法和预想的一样朴素：",[46,976,977],{},"把状态放在能读到的地方，并且让用户看见。",[578,979,980],{},"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 .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":38,"searchDepth":280,"depth":280,"links":982},[983,984,985,986,987,988,989],{"id":625,"depth":280,"text":626},{"id":685,"depth":280,"text":686},{"id":715,"depth":280,"text":716},{"id":781,"depth":280,"text":782},{"id":871,"depth":280,"text":872},{"id":885,"depth":280,"text":886},{"id":895,"depth":280,"text":896},"2026-08-16","同一天做了两件事。表面上一个关于文件系统，一个关于流式生成，实际在解决同一个问题：长动作要么在一开始就能定好条件，要么在过程中能叫停。",{},"\u002F2026-08-16",{"title":614,"description":991},"2026-08-16-让会话记住目录让回答能停下","两个看似无关的功能：把工作目录从单次工具调用提升为会话级能力；让正在生成的回答可以中途停止并继续。共同点是给长动作加上用户的控制权。",[998,999,1000,1001,1002,1003],"桌面端","会话状态","文件系统","路径校验","流式生成","中断","jiUeJkYnicdLCfFjfh09Sim0_nWpiER2NhMrT2FeRKQ",{"id":1006,"title":1007,"body":1008,"column":594,"date":1854,"description":1012,"extension":597,"hero_image":598,"meta":1855,"navigation":289,"path":1856,"seo":1857,"series_id":598,"severity":598,"stem":1858,"summary":1859,"tags":1860,"__hash__":1864},"posts\u002F2026-07-07-桌面端只做壳不装页面.md","桌面端只做壳，不装页面",{"type":7,"value":1009,"toc":1844},[1010,1013,1016,1023,1027,1030,1040,1170,1176,1195,1210,1229,1243,1247,1253,1262,1268,1272,1278,1284,1287,1293,1368,1371,1374,1389,1392,1408,1415,1438,1441,1445,1448,1458,1465,1482,1485,1588,1594,1610,1613,1620,1624,1627,1652,1658,1668,1674,1742,1749,1759,1778,1781,1785,1792,1799,1802,1806,1809,1815,1818,1821,1825,1828,1831,1838,1841],[10,1011,1012],{},"Windows 客户端有两种做法。",[10,1014,1015],{},"一种是把前端打进安装包，装完之后本地有一份完整页面。另一种是桌面端只做壳，窗口加载远端地址。这条线选了后者。",[10,1017,1018,1019,1022],{},"选择的结果是：7 月 7 日一天发了两个版本（1.0.19、1.0.20），两次都只动了 ",[14,1020,1021],{},"apps\u002Fdesktop"," 自己的代码。",[19,1024,1026],{"id":1025},"一壳里没有页面","一、壳里没有页面",[10,1028,1029],{},"最直接的证据是渲染进程的产物。",[10,1031,1032,1035,1036,1039],{},[14,1033,1034],{},"electron-vite"," 的渲染进程配置指向 ",[14,1037,1038],{},"src\u002Frenderer\u002Findex.html","，而这个文件全部内容是：",[30,1041,1045],{"className":1042,"code":1043,"language":1044,"meta":38,"style":38},"language-html shiki shiki-themes github-light github-dark","\u003C!DOCTYPE html>\n\u003Chtml>\n\u003Chead>\n  \u003Cmeta charset=\"UTF-8\" \u002F>\n  \u003Ctitle>创玩猩球\u003C\u002Ftitle>\n\u003C\u002Fhead>\n\u003Cbody>\n  \u003Cdiv id=\"app\">Loading...\u003C\u002Fdiv>\n\u003C\u002Fbody>\n\u003C\u002Fhtml>\n","html",[14,1046,1047,1061,1070,1079,1098,1112,1121,1130,1152,1161],{"__ignoreMap":38},[271,1048,1049,1052,1056,1059],{"class":273,"line":274},[271,1050,1051],{"class":338},"\u003C!",[271,1053,1055],{"class":1054},"s9eBZ","DOCTYPE",[271,1057,1058],{"class":350}," html",[271,1060,461],{"class":338},[271,1062,1063,1066,1068],{"class":273,"line":280},[271,1064,1065],{"class":338},"\u003C",[271,1067,1044],{"class":1054},[271,1069,461],{"class":338},[271,1071,1072,1074,1077],{"class":273,"line":286},[271,1073,1065],{"class":338},[271,1075,1076],{"class":1054},"head",[271,1078,461],{"class":338},[271,1080,1081,1084,1087,1090,1092,1095],{"class":273,"line":293},[271,1082,1083],{"class":338},"  \u003C",[271,1085,1086],{"class":1054},"meta",[271,1088,1089],{"class":350}," charset",[271,1091,343],{"class":338},[271,1093,1094],{"class":346},"\"UTF-8\"",[271,1096,1097],{"class":338}," \u002F>\n",[271,1099,1100,1102,1105,1108,1110],{"class":273,"line":299},[271,1101,1083],{"class":338},[271,1103,1104],{"class":1054},"title",[271,1106,1107],{"class":338},">创玩猩球\u003C\u002F",[271,1109,1104],{"class":1054},[271,1111,461],{"class":338},[271,1113,1114,1117,1119],{"class":273,"line":464},[271,1115,1116],{"class":338},"\u003C\u002F",[271,1118,1076],{"class":1054},[271,1120,461],{"class":338},[271,1122,1123,1125,1128],{"class":273,"line":469},[271,1124,1065],{"class":338},[271,1126,1127],{"class":1054},"body",[271,1129,461],{"class":338},[271,1131,1132,1134,1137,1140,1142,1145,1148,1150],{"class":273,"line":475},[271,1133,1083],{"class":338},[271,1135,1136],{"class":1054},"div",[271,1138,1139],{"class":350}," id",[271,1141,343],{"class":338},[271,1143,1144],{"class":346},"\"app\"",[271,1146,1147],{"class":338},">Loading...\u003C\u002F",[271,1149,1136],{"class":1054},[271,1151,461],{"class":338},[271,1153,1155,1157,1159],{"class":273,"line":1154},9,[271,1156,1116],{"class":338},[271,1158,1127],{"class":1054},[271,1160,461],{"class":338},[271,1162,1164,1166,1168],{"class":273,"line":1163},10,[271,1165,1116],{"class":338},[271,1167,1044],{"class":1054},[271,1169,461],{"class":338},[10,1171,1172,1173,76],{},"没有 ",[14,1174,1175],{},"\u003Cscript>",[10,1177,1178,1179,1182,1183,1186,1187,1190,1191,1194],{},"构建产物三个文件：",[14,1180,1181],{},"out\u002Fmain\u002Findex.js","（884K）、",[14,1184,1185],{},"out\u002Fpreload\u002Findex.cjs","（4K）、",[14,1188,1189],{},"out\u002Frenderer\u002Findex.html","（",[46,1192,1193],{},"147 字节","）。",[10,1196,1197,1198,1201,1202,1205,1206,1209],{},"全仓搜索 ",[14,1199,1200],{},"loadFile"," \u002F ",[14,1203,1204],{},"file:\u002F\u002F","，",[14,1207,1208],{},"apps\u002Fdesktop\u002Fsrc\u002F"," 下没有任何一处加载本地 renderer。主窗口创建完就一件事：",[30,1211,1213],{"className":727,"code":1212,"language":729,"meta":38,"style":38},"void win.loadURL(cfg.webUrl);\n",[14,1214,1215],{"__ignoreMap":38},[271,1216,1217,1220,1223,1226],{"class":273,"line":274},[271,1218,1219],{"class":342},"void",[271,1221,1222],{"class":338}," win.",[271,1224,1225],{"class":350},"loadURL",[271,1227,1228],{"class":338},"(cfg.webUrl);\n",[10,1230,1231,1232,1235,1236,1235,1239,1242],{},"打包配置里的文件白名单也只列了三项：",[14,1233,1234],{},"out\u002F**\u002F*","、",[14,1237,1238],{},"resources\u002F**\u002F*",[14,1240,1241],{},"package.json","。前端产物不在其中——不是通过某个「排除 web」的配置项排除掉的，是从来没被放进来过。",[19,1244,1246],{"id":1245},"二换来的东西","二、换来的东西",[10,1248,1249,1252],{},[46,1250,1251],{},"web 发版不动桌面端。"," 页面改动走云端发版，用户下次打开就是新版本，不需要装任何东西。这也是 7 月 7 日能一天发两版的原因：两次改动都只涉及桌面端自己的代码。",[10,1254,1255,1258,1259,1261],{},[46,1256,1257],{},"更新包小。"," OTA 只依赖 ",[14,1260,1021],{}," 源码加一个协议包。更新包的内容就是主进程、预加载、几个本地二进制。",[10,1263,1264,1267],{},[46,1265,1266],{},"两端的版本可以独立走。"," 没有「前端必须等桌面端一起发」这种依赖。",[19,1269,1271],{"id":1270},"三代价","三、代价",[10,1273,1274,1277],{},[46,1275,1276],{},"离线完全不可用。"," 桌面端的 README 里，「暂未实现」清单下有一行：",[30,1279,1282],{"className":1280,"code":1281,"language":35},[33],"- 离线模式与本地回放\n",[14,1283,1281],{"__ignoreMap":38},[10,1285,1286],{},"没有本地缓存页、没有离线兜底。断网时窗口是空的。",[10,1288,1289,1292],{},[46,1290,1291],{},"首屏依赖网络。"," 主窗口的加载失败处理只做了一件事——标记这次启动是成功的：",[30,1294,1296],{"className":727,"code":1295,"language":729,"meta":38,"style":38},"win.webContents.on(\"did-fail-load\", (_e, _code, _desc, _url, isMainFrame) => {\n  if (isMainFrame) launchGuard.markLaunchSucceeded();\n});\n",[14,1297,1298,1349,1363],{"__ignoreMap":38},[271,1299,1300,1303,1306,1309,1312,1315,1319,1322,1325,1327,1330,1332,1335,1337,1340,1343,1346],{"class":273,"line":274},[271,1301,1302],{"class":338},"win.webContents.",[271,1304,1305],{"class":350},"on",[271,1307,1308],{"class":338},"(",[271,1310,1311],{"class":346},"\"did-fail-load\"",[271,1313,1314],{"class":338},", (",[271,1316,1318],{"class":1317},"s4XuR","_e",[271,1320,1321],{"class":338},", ",[271,1323,1324],{"class":1317},"_code",[271,1326,1321],{"class":338},[271,1328,1329],{"class":1317},"_desc",[271,1331,1321],{"class":338},[271,1333,1334],{"class":1317},"_url",[271,1336,1321],{"class":338},[271,1338,1339],{"class":1317},"isMainFrame",[271,1341,1342],{"class":338},") ",[271,1344,1345],{"class":342},"=>",[271,1347,1348],{"class":338}," {\n",[271,1350,1351,1354,1357,1360],{"class":273,"line":280},[271,1352,1353],{"class":342},"  if",[271,1355,1356],{"class":338}," (isMainFrame) launchGuard.",[271,1358,1359],{"class":350},"markLaunchSucceeded",[271,1361,1362],{"class":338},"();\n",[271,1364,1365],{"class":273,"line":286},[271,1366,1367],{"class":338},"});\n",[10,1369,1370],{},"这个设计的用意是区分「渲染进程起不来」和「网页加载失败」：网页加载失败说明沙箱正常，不该触发降级。但它也意味着主窗口没有错误页——加载失败时用户看到的是一块白窗口。",[10,1372,1373],{},"对比之下，agent 的内置浏览器有兜底页，注释写得很清楚：",[30,1375,1377],{"className":727,"code":1376,"language":729,"meta":38,"style":38},"\u002F\u002F 加载失败\u002F渲染崩溃时展示的兜底页：把失败原因和 URL 明明白白写出来，\n\u002F\u002F 取代过去那块「无提示纯白窗口」。\n",[14,1378,1379,1384],{"__ignoreMap":38},[271,1380,1381],{"class":273,"line":274},[271,1382,1383],{"class":400},"\u002F\u002F 加载失败\u002F渲染崩溃时展示的兜底页：把失败原因和 URL 明明白白写出来，\n",[271,1385,1386],{"class":273,"line":280},[271,1387,1388],{"class":400},"\u002F\u002F 取代过去那块「无提示纯白窗口」。\n",[10,1390,1391],{},"主窗口这一条还没补。",[10,1393,1394,1397,1398,1235,1401,1235,1404,1407],{},[46,1395,1396],{},"两端版本要各自维护兼容边界。"," 这条是最不确定的。目前桌面端和 web 之间只有三个地址契约（",[14,1399,1400],{},"webUrl",[14,1402,1403],{},"apiBase",[14,1405,1406],{},"wsUrl","），没有版本号交换，也没有「桌面端版本过低就拒绝服务」的门槛。",[10,1409,1410,1411,1414],{},"设备注册时会上报 ",[14,1412,1413],{},"appVersion","，云端落库——但它的用途是记录，不是校验。兼容靠的是「能力清单 + 可选字段」：老版本不带某个字段时，新接口按缺省处理。协议包里有对应的测试：",[30,1416,1418],{"className":727,"code":1417,"language":729,"meta":38,"style":38},"it(\"没有工作目录时不带该字段，老桌面端行为不变\", ...)\n",[14,1419,1420],{"__ignoreMap":38},[271,1421,1422,1425,1427,1430,1432,1435],{"class":273,"line":274},[271,1423,1424],{"class":350},"it",[271,1426,1308],{"class":338},[271,1428,1429],{"class":346},"\"没有工作目录时不带该字段，老桌面端行为不变\"",[271,1431,1321],{"class":338},[271,1433,1434],{"class":342},"...",[271,1436,1437],{"class":338},")\n",[10,1439,1440],{},"测试清单里有一条 P1 是「旧桌面端连新后端不崩」。这条列在那里本身就说明它还没有被系统验证过。",[19,1442,1444],{"id":1443},"四壳里真正装的是什么","四、壳里真正装的是什么",[10,1446,1447],{},"如果桌面端什么都不装，它就没必要存在。装的是三类本地运行时。",[10,1449,1450,1453,1454,1457],{},[46,1451,1452],{},"Python 运行时。"," 打包一份 Python 3.12.7 的 embed 版本，让 AI 的 ",[14,1455,1456],{},"python xxx.py"," 在任何客户机器上都能跑，不需要客户自己装 Python。",[10,1459,1460,1461,1464],{},"它必须走 ",[14,1462,1463],{},"extraResources"," 而不是进 asar，配置里写了原因：",[30,1466,1470],{"className":1467,"code":1468,"language":1469,"meta":38,"style":38},"language-js shiki shiki-themes github-light github-dark","\u002F\u002F 自带 Python 运行时：二进制必须走 extraResources（解压到 app\u002Fresources 下、不进 asar 才能执行）。\n\u002F\u002F 由 scripts\u002Ffetch-pyruntime.mjs 预先下载到 resources\u002Fpyruntime\u002F\u003C平台>\u002F。\n","js",[14,1471,1472,1477],{"__ignoreMap":38},[271,1473,1474],{"class":273,"line":274},[271,1475,1476],{"class":400},"\u002F\u002F 自带 Python 运行时：二进制必须走 extraResources（解压到 app\u002Fresources 下、不进 asar 才能执行）。\n",[271,1478,1479],{"class":273,"line":280},[271,1480,1481],{"class":400},"\u002F\u002F 由 scripts\u002Ffetch-pyruntime.mjs 预先下载到 resources\u002Fpyruntime\u002F\u003C平台>\u002F。\n",[10,1483,1484],{},"运行时目录从安装目录解析，也不可能从网络取：",[30,1486,1488],{"className":727,"code":1487,"language":729,"meta":38,"style":38},"const base = join(resourcesPath, \"pyruntime\", sub);\nconst exe = process.platform === \"win32\" ? join(base, \"python.exe\") : join(base, \"bin\", \"python3\");\nif (!existsSync(exe)) return null;\n",[14,1489,1490,1513,1562],{"__ignoreMap":38},[271,1491,1492,1495,1498,1501,1504,1507,1510],{"class":273,"line":274},[271,1493,1494],{"class":342},"const",[271,1496,1497],{"class":368}," base",[271,1499,1500],{"class":342}," =",[271,1502,1503],{"class":350}," join",[271,1505,1506],{"class":338},"(resourcesPath, ",[271,1508,1509],{"class":346},"\"pyruntime\"",[271,1511,1512],{"class":338},", sub);\n",[271,1514,1515,1517,1520,1522,1525,1528,1531,1534,1536,1539,1542,1544,1547,1549,1551,1554,1556,1559],{"class":273,"line":280},[271,1516,1494],{"class":342},[271,1518,1519],{"class":368}," exe",[271,1521,1500],{"class":342},[271,1523,1524],{"class":338}," process.platform ",[271,1526,1527],{"class":342},"===",[271,1529,1530],{"class":346}," \"win32\"",[271,1532,1533],{"class":342}," ?",[271,1535,1503],{"class":350},[271,1537,1538],{"class":338},"(base, ",[271,1540,1541],{"class":346},"\"python.exe\"",[271,1543,1342],{"class":338},[271,1545,1546],{"class":342},":",[271,1548,1503],{"class":350},[271,1550,1538],{"class":338},[271,1552,1553],{"class":346},"\"bin\"",[271,1555,1321],{"class":338},[271,1557,1558],{"class":346},"\"python3\"",[271,1560,1561],{"class":338},");\n",[271,1563,1564,1567,1570,1573,1576,1579,1582,1585],{"class":273,"line":286},[271,1565,1566],{"class":342},"if",[271,1568,1569],{"class":338}," (",[271,1571,1572],{"class":342},"!",[271,1574,1575],{"class":350},"existsSync",[271,1577,1578],{"class":338},"(exe)) ",[271,1580,1581],{"class":342},"return",[271,1583,1584],{"class":368}," null",[271,1586,1587],{"class":338},";\n",[10,1589,1590,1593],{},[46,1591,1592],{},"VC++ 运行库。"," 安装期静默装，已装则跳过，避免多余的 UAC 弹窗。",[10,1595,1596,1599,1600,1235,1603,1235,1606,1609],{},[46,1597,1598],{},"Electron 自己的运行库。"," 有一个设计文档专门钉了一份「核心文件清单」：主程序 exe、",[14,1601,1602],{},"ffmpeg.dll",[14,1604,1605],{},"chrome_elf.dll",[14,1607,1608],{},"resources\u002Fapp.asar","，检查发生在安装包解包完成之后、启动新程序之前。",[10,1611,1612],{},"这份清单来自一次真实故障：用户从旧版自动更新之后，启动时报「找不到 ffmpeg.dll」。安装包里其实有这个文件——错误发生在 Electron 主程序加载阶段，那时应用自己的错误处理还没机会运行，所以只能靠安装器检查。",[10,1614,1615,1616,1619],{},"这三类东西的共同点是",[46,1617,1618],{},"它们不能从远端加载","。Python 解释器、运行库 dll、Electron 自身，都必须在本地文件系统上。这才是壳存在的理由，不是为了渲染页面。",[19,1621,1623],{"id":1622},"五发版链路","五、发版链路",[10,1625,1626],{},"四个步骤，脚本里编号就是四段：",[30,1628,1630],{"className":329,"code":1629,"language":331,"meta":38,"style":38},"# [1\u002F4] 同步源码（只覆盖源码，保留 node_modules \u002F pyruntime \u002F dist）\n# [2\u002F4] 构建 dist:win（含 pyruntime、签名）\n# [3\u002F4] 校验 latest.yml 版本 == $VERSION\n# [4\u002F4] 上传 OTA\n",[14,1631,1632,1637,1642,1647],{"__ignoreMap":38},[271,1633,1634],{"class":273,"line":274},[271,1635,1636],{"class":400},"# [1\u002F4] 同步源码（只覆盖源码，保留 node_modules \u002F pyruntime \u002F dist）\n",[271,1638,1639],{"class":273,"line":280},[271,1640,1641],{"class":400},"# [2\u002F4] 构建 dist:win（含 pyruntime、签名）\n",[271,1643,1644],{"class":273,"line":286},[271,1645,1646],{"class":400},"# [3\u002F4] 校验 latest.yml 版本 == $VERSION\n",[271,1648,1649],{"class":273,"line":293},[271,1650,1651],{"class":400},"# [4\u002F4] 上传 OTA\n",[10,1653,1654,1657],{},[46,1655,1656],{},"为什么是「scp 源码到构建机」而不是在构建机上 clone。"," 构建机无法非交互拉取私有仓库（HTTPS 没有 tty，SSH 公钥没授权），所以从工作机把源码推过去，在一个「依赖已装、运行时齐全、密钥在系统环境变量」的固定构建目录里出包。",[10,1659,1660,1663,1664,1667],{},[46,1661,1662],{},"为什么走镜像。"," electron-builder 在国内拉 GitHub releases 会超时，报的是 ",[14,1665,1666],{},"Timeout awaiting 'request' for 600000ms","。构建前设两个镜像环境变量绕开。",[10,1669,1670,1673],{},[46,1671,1672],{},"版本校验有两道。"," 第一道在脚本里，出包之后比对产物清单里的版本和目标版本，不符就中止，不上传：",[30,1675,1677],{"className":329,"code":1676,"language":331,"meta":38,"style":38},"[[ \"$ONVER\" == \"$VERSION\" ]] || { echo \"产物版本($ONVER) != 目标($VERSION)，中止上传。\" >&2; exit 1; }\n",[14,1678,1679],{"__ignoreMap":38},[271,1680,1681,1684,1687,1690,1692,1695,1697,1700,1702,1705,1708,1711,1714,1717,1719,1722,1724,1727,1730,1733,1736,1739],{"class":273,"line":274},[271,1682,1683],{"class":338},"[[ ",[271,1685,1686],{"class":346},"\"",[271,1688,1689],{"class":338},"$ONVER",[271,1691,1686],{"class":346},[271,1693,1694],{"class":342}," ==",[271,1696,415],{"class":346},[271,1698,1699],{"class":338},"$VERSION",[271,1701,1686],{"class":346},[271,1703,1704],{"class":338}," ]] ",[271,1706,1707],{"class":342},"||",[271,1709,1710],{"class":338}," { ",[271,1712,1713],{"class":368},"echo",[271,1715,1716],{"class":346}," \"产物版本(",[271,1718,1689],{"class":338},[271,1720,1721],{"class":346},") != 目标(",[271,1723,1699],{"class":338},[271,1725,1726],{"class":346},")，中止上传。\"",[271,1728,1729],{"class":342}," >&2",[271,1731,1732],{"class":338},"; ",[271,1734,1735],{"class":368},"exit",[271,1737,1738],{"class":368}," 1",[271,1740,1741],{"class":338},"; }\n",[10,1743,1744,1745,1748],{},"第二道在产物选择里：只允许当前版本的三件套（安装包、blockmap、清单）上传，缺任何一个直接抛错。这一道是为了处理「",[14,1746,1747],{},"dist"," 目录不清、旧版安装包残留」的情况。",[10,1750,1751,1754,1755,1758],{},[46,1752,1753],{},"客户端侧。"," 更新走 ",[14,1756,1757],{},"electron-updater"," 的 generic provider，指向一个静态目录：",[30,1760,1762],{"className":1467,"code":1761,"language":1469,"meta":38,"style":38},"publish: [{ provider: \"generic\", url: updateUrl }],\n",[14,1763,1764],{"__ignoreMap":38},[271,1765,1766,1769,1772,1775],{"class":273,"line":274},[271,1767,1768],{"class":350},"publish",[271,1770,1771],{"class":338},": [{ provider: ",[271,1773,1774],{"class":346},"\"generic\"",[271,1776,1777],{"class":338},", url: updateUrl }],\n",[10,1779,1780],{},"启动时拉清单文件、比较版本、下载、静默安装。",[19,1782,1784],{"id":1783},"六版本号的唯一来源","六、版本号的唯一来源",[10,1786,1787,1788,1791],{},"版本号只在 ",[14,1789,1790],{},"apps\u002Fdesktop\u002Fpackage.json"," 一处。产物名和清单里的版本都由它决定。",[10,1793,1794,1795,1798],{},"与之配套的一条纪律：",[46,1796,1797],{},"绝不能复用旧版本号","。客户端判断「要不要更新」靠的是版本比较，版本号相同就等于没有新版本，用户那边什么都不会发生——而这种失败是静默的，服务端看到的是上传成功。",[10,1800,1801],{},"脚本的这条判断还有一个前置的护栏：工作区有未提交的已跟踪改动时拒绝发版。因为发出去的是工作区源码，不是某个 commit。",[19,1803,1805],{"id":1804},"七一处没对齐的地方","七、一处没对齐的地方",[10,1807,1808],{},"签名。桌面端 README 写的是：",[30,1810,1813],{"className":1811,"code":1812,"language":35},[33],"当前 Windows 安装器未接入 CA 代码签名，用户首次安装可能看到 SmartScreen 提示。\n拿到证书后再通过 WIN_CSC_LINK \u002F WIN_CSC_KEY_PASSWORD 接入签名流程。\n",[14,1814,1812],{"__ignoreMap":38},[10,1816,1817],{},"而发版 skill 里写的是构建机已配 signtool、产出无 SmartScreen 强拦。两处说法不一致，仓库里也搜不到任何签名实现——那两个环境变量只出现在这一行文档里。",[10,1819,1820],{},"这两句话都写在 7 月上旬，中间没有改动过配置。真实状态是：安装包没有 CA 签名，首次安装会出现 SmartScreen 提示。文档里那句「无强拦」应该改掉。",[19,1822,1824],{"id":1823},"八这条路线适合什么","八、这条路线适合什么",[10,1826,1827],{},"选它之前要接受三个前提：用户始终在线、接受首次打开有网络等待、接受两端各自演进。",[10,1829,1830],{},"换来的是发版节奏的自由：改一个页面不需要用户装新版本，改壳里的东西不需要等前端一起走。前者让迭代变快，后者让迭代变危险——OTA 是推给所有人的，很难回滚。",[10,1832,1833,1834,1837],{},"所以这条路线上的纪律集中在一点：",[46,1835,1836],{},"发版前的校验要做足","。两道版本门禁、一条工作区护栏、一个只构建不上传的选项。四道关卡守的都是同一件事：不要把一个身份不明的包推给全部用户。",[10,1839,1840],{},"有一处边界想清楚过：安装包必须包含哪些文件。这份清单不是设计出来的，是被一次「找不到 ffmpeg.dll」的故障逼出来的。清单上的文件缺任何一个，安装器就在启动新程序之前停下来——因为在那个时刻，应用自己的任何补救代码都还没机会运行。",[578,1842,1843],{},"html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}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 .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}",{"title":38,"searchDepth":280,"depth":280,"links":1845},[1846,1847,1848,1849,1850,1851,1852,1853],{"id":1025,"depth":280,"text":1026},{"id":1245,"depth":280,"text":1246},{"id":1270,"depth":280,"text":1271},{"id":1443,"depth":280,"text":1444},{"id":1622,"depth":280,"text":1623},{"id":1783,"depth":280,"text":1784},{"id":1804,"depth":280,"text":1805},{"id":1823,"depth":280,"text":1824},"2026-07-07",{},"\u002F2026-07-07",{"title":1007,"description":1012},"2026-07-07-桌面端只做壳不装页面","桌面端只加载远端地址，不把前端打进安装包。这条路线换来了独立的发版节奏，代价是离线不可用、首屏依赖网络，以及壳里必须自带的那部分本地运行时。",[1861,998,605,1862,1863,607],"Electron","electron-builder","打包","ElNTxouudoGHuSXPhaMwM-u23sOVeK4HA3GAGBnmito",{"id":1866,"title":1867,"body":1868,"column":594,"date":2344,"description":1872,"extension":597,"hero_image":598,"meta":2345,"navigation":289,"path":2346,"seo":2347,"series_id":598,"severity":598,"stem":2348,"summary":2349,"tags":2350,"__hash__":2354},"posts\u002F2026-06-16-把高风险发版固化成skill.md","不让 AI 碰发版，不等于靠人记住每一步",{"type":7,"value":1869,"toc":2338},[1870,1873,1877,1880,1883,1902,1913,1916,1920,1923,1926,2045,2052,2055,2062,2091,2094,2101,2105,2108,2111,2118,2188,2199,2260,2263,2278,2285,2288,2305,2312,2315,2318,2329,2332,2335],[10,1871,1872],{},"发版从来不应该由人工记忆驱动。5 月那一轮 OTA 系列故障（FA-005 到 FA-008）和跨版本步进导致的漏 DLL 事故反复说明了这一点：发版是不可逆的、影响全部用户的、细节繁琐的操作，任何一个环节遗漏或顺序错误就导致客户端卡死、文件不一致、激活页死循环。我现在不是让 AI 执行发版——那是禁地——而是把 5 个已经踏过所有坑的发版流程全部固化成 skill，让执行者用最小脑力成本跑完完整链路，而不是依赖文档翻译和记忆。",[19,1874,1876],{"id":1875},"触发词不该触发时绝对不触发","触发词：不该触发时绝对不触发",[10,1878,1879],{},"一个 skill 最危险的时刻是被错误调用。\"现在是什么版本\"听起来像发版，\"改一下版本号\"听起来像发版，但它们完全不是。",[10,1881,1882],{},"Wakou 发版 skill 的触发词清单（必读触发词段）就是为了卡死这条线：",[151,1884,1885,1891],{},[154,1886,1887,1888],{},"触发：",[14,1889,1890],{},"发版 \u002F 发布 \u002F release \u002F 打个新版本 \u002F OTA \u002F OTA 死循环 \u002F 版本 bump \u002F \u002Fwakou-release",[154,1892,1893,1894,1897,1898,1901],{},"不触发：",[14,1895,1896],{},"改一下 package.json 版本号","（用户只想改文件），",[14,1899,1900],{},"看看现在是什么版本","（只查询）",[10,1903,1904,1905,1908,1909,1912],{},"反面教材我都经历过。改一下配置文件、改一个环境变量、查一个版本号，因为没有明确的触发词界限，最后莫名其妙走到了发版脚本的某一步。Connector OTA skill 同样列明了不触发场景：",[14,1906,1907],{},"\"Connector 现在是什么版本\"(只查询)"," ",[14,1910,1911],{},"\"改 tauri.conf 版本号\"(用户只想改文件)"," — 这些都不触发，即使命令里含了 version、release 这类关键字。",[10,1914,1915],{},"这条最反直觉但最重要：不该触发时被触发，等于主动送一次误操作机会给自动化流程。",[19,1917,1919],{"id":1918},"步骤不可跳过验证链条硬卡","步骤不可跳过：验证链条硬卡",[10,1921,1922],{},"一旦 skill 确定要执行，每一步都必须有验证关卡，前一步没通过绝不进下一步。我用 Wakou 全自动发版流程作例子。",[10,1924,1925],{},"预检（Step 1）是整个流程的闸门。跑这些 Bash 检查：",[30,1927,1929],{"className":329,"code":1928,"language":331,"meta":38,"style":38},"cd \u002FUsers\u002Fxingye\u002FAi\u002Fnew-openclaw\ngit status --short                                          # 工作区干净\ntest -x \u002Fopt\u002Fhomebrew\u002Fbin\u002Fsshpass                           # ssh 工具\ntest -x \u002Fopt\u002Fhomebrew\u002Fbin\u002Fminisign                          # 签名工具\ntest -x \u003C签名 CLI 路径>                                      # 内部签名工具就位\ntest -f \u003C发版签名私钥路径>                                   # 私钥就位\ntest -f \u003CCDN 上传凭据路径>                                   # CDN 凭据就位\n",[14,1930,1931,1939,1953,1967,1979,2005,2024],{"__ignoreMap":38},[271,1932,1933,1936],{"class":273,"line":274},[271,1934,1935],{"class":368},"cd",[271,1937,1938],{"class":346}," \u002FUsers\u002Fxingye\u002FAi\u002Fnew-openclaw\n",[271,1940,1941,1944,1947,1950],{"class":273,"line":280},[271,1942,1943],{"class":350},"git",[271,1945,1946],{"class":346}," status",[271,1948,1949],{"class":368}," --short",[271,1951,1952],{"class":400},"                                          # 工作区干净\n",[271,1954,1955,1958,1961,1964],{"class":273,"line":286},[271,1956,1957],{"class":368},"test",[271,1959,1960],{"class":368}," -x",[271,1962,1963],{"class":346}," \u002Fopt\u002Fhomebrew\u002Fbin\u002Fsshpass",[271,1965,1966],{"class":400},"                           # ssh 工具\n",[271,1968,1969,1971,1973,1976],{"class":273,"line":293},[271,1970,1957],{"class":368},[271,1972,1960],{"class":368},[271,1974,1975],{"class":346}," \u002Fopt\u002Fhomebrew\u002Fbin\u002Fminisign",[271,1977,1978],{"class":400},"                          # 签名工具\n",[271,1980,1981,1983,1985,1987,1990,1993,1996,1999,2002],{"class":273,"line":299},[271,1982,1957],{"class":368},[271,1984,1960],{"class":368},[271,1986,452],{"class":342},[271,1988,1989],{"class":346},"签名",[271,1991,1992],{"class":346}," CLI",[271,1994,1995],{"class":346}," 路",[271,1997,1998],{"class":338},"径",[271,2000,2001],{"class":342},">",[271,2003,2004],{"class":400},"                                      # 内部签名工具就位\n",[271,2006,2007,2009,2012,2014,2017,2019,2021],{"class":273,"line":464},[271,2008,1957],{"class":368},[271,2010,2011],{"class":368}," -f",[271,2013,452],{"class":342},[271,2015,2016],{"class":346},"发版签名私钥路",[271,2018,1998],{"class":338},[271,2020,2001],{"class":342},[271,2022,2023],{"class":400},"                                   # 私钥就位\n",[271,2025,2026,2028,2030,2032,2035,2038,2040,2042],{"class":273,"line":469},[271,2027,1957],{"class":368},[271,2029,2011],{"class":368},[271,2031,452],{"class":342},[271,2033,2034],{"class":346},"CDN",[271,2036,2037],{"class":346}," 上传凭据路",[271,2039,1998],{"class":338},[271,2041,2001],{"class":342},[271,2043,2044],{"class":400},"                                   # CDN 凭据就位\n",[10,2046,2047,2048,2051],{},"任何一条 fail，流程停止并告诉用户修什么。如果 git 工作区脏，不是自动 stash，而是问用户：工作区有未提交改动，要先 commit 还是 stash？发版会自动 bump 三个版本文件并 commit。这样做的目的很明确——让用户",[46,2049,2050],{},"清晰意识到","发版会改动工作区，而不是闭着眼睛走入自动流程。",[10,2053,2054],{},"收三个密码（Step 2）也有安全约定。不要把密码记到内存或 commit 进文件，只读 shell 的 export。如果用户在过去几分钟内说过这些密码（在 conversation context 里能直接拿到），可以直接传 env 跑；否则必须用户主动导入。",[10,2056,2057,2058,2061],{},"跑 release.sh（Step 3）用 ",[14,2059,2060],{},"run_in_background=true"," 避免 5-15 分钟阻塞主线，把输出 tee 到日志。然后（Step 4）用 Monitor 工具监控 10 个 stage 的进度：",[511,2063,2064,2067,2070,2073,2076,2079,2082,2085,2088],{},[154,2065,2066],{},"preflight",[154,2068,2069],{},"detect FROM_VERSION",[154,2071,2072],{},"tar + scp source to Win build host",[154,2074,2075],{},"build on Win",[154,2077,2078],{},"stage + diff-pack OTA full.zip",[154,2080,2081],{},"minisign full.zip + release-manifest.json",[154,2083,2084],{},"upload to CDN",[154,2086,2087],{},"admin: create release + PATCH rollout",[154,2089,2090],{},"git tag + commit version bump",[10,2092,2093],{},"每个 stage 完成给用户报一句。出现 ✗ FAILED 立刻 surface 错误。没有\"继续试试能不能救\"，就是暴露失败。",[10,2095,2096,2097,2100],{},"这整套链条的目的是",[46,2098,2099],{},"让人工审核点布满全流程","。不是机器自动发版，而是 AI 陪着用户一步步走完发版，每步都看得清、验得出。",[19,2102,2104],{"id":2103},"故障处置内联把坑写在流程里","故障处置内联：把坑写在流程里",[10,2106,2107],{},"OTA 系列故障的教训不应该沉在 bug 复盘里。我把它们直接铺进 skill 的执行逻辑。",[10,2109,2110],{},"Wakou 发版 skill 有整整一章叫「🚨 阻塞性 bug 处置 SOP」。列出了从 G19 到 G31 的 11 个已知 bug：",[10,2112,2113,2114,2117],{},"G19 是 OTA modal 反复弹。原因是 state base 残留 stale ",[14,2115,2116],{},"download.zip.minisig","，当时的修复是：",[30,2119,2121],{"className":329,"code":2120,"language":331,"meta":38,"style":38},"# 预检 sanity#3: data\u002F 不删\nsanity#3 强制阻断 deletes 命中 data\u002F。某次发版失败提示 sanity#3 一定是 build 步骤搞坏了 data\u002F 目录的同步，不要绕过 sanity check 去强发，先 debug 为什么 data\u002F 出现在 deletes 列表。\n",[14,2122,2123,2128],{"__ignoreMap":38},[271,2124,2125],{"class":273,"line":274},[271,2126,2127],{"class":400},"# 预检 sanity#3: data\u002F 不删\n",[271,2129,2130,2133,2136,2139,2142,2145,2148,2151,2154,2157,2160,2163,2166,2169,2172,2175,2178,2180,2183,2185],{"class":273,"line":280},[271,2131,2132],{"class":350},"sanity#3",[271,2134,2135],{"class":346}," 强制阻断",[271,2137,2138],{"class":346}," deletes",[271,2140,2141],{"class":346}," 命中",[271,2143,2144],{"class":346}," data\u002F。某次发版失败提示",[271,2146,2147],{"class":346}," sanity#3",[271,2149,2150],{"class":346}," 一定是",[271,2152,2153],{"class":346}," build",[271,2155,2156],{"class":346}," 步骤搞坏了",[271,2158,2159],{"class":346}," data\u002F",[271,2161,2162],{"class":346}," 目录的同步，不要绕过",[271,2164,2165],{"class":346}," sanity",[271,2167,2168],{"class":346}," check",[271,2170,2171],{"class":346}," 去强发，先",[271,2173,2174],{"class":346}," debug",[271,2176,2177],{"class":346}," 为什么",[271,2179,2159],{"class":346},[271,2181,2182],{"class":346}," 出现在",[271,2184,2138],{"class":346},[271,2186,2187],{"class":346}," 列表。\n",[10,2189,2190,2191,2194,2195,2198],{},"G25 是跨用户机器后 chat 权限错。根因是 ",[14,2192,2193],{},"sessions\u002Faudit"," 含绝对路径，guardian sync 扩散。这不是\"修了以后的故事\"，而是",[46,2196,2197],{},"发版前防御 checklist"," 的一部分：",[30,2200,2202],{"className":329,"code":2201,"language":331,"meta":38,"style":38},"# 发版前防御 checklist (每次发版前在 build host 做完整新用户回归)\n# 3. dist 内 grep C:\\Users\\ 应该 0 命中（防 G25 类绝对路径泄露）\nssh CHANCHING@\u003C构建机> 'powershell -Command \"\n  Get-ChildItem D:\\wakou-build\\dist-X.Y.Z\\WakouPanelPortable -Recurse -File -Include *.json,*.jsonl |\n    Where-Object { $_.Length -lt 200KB } |\n    Select-String C:\\\\Users\\\\Administrator -SimpleMatch -List |\n    Select-Object FullName\n\"'\n",[14,2203,2204,2209,2214,2235,2240,2245,2250,2255],{"__ignoreMap":38},[271,2205,2206],{"class":273,"line":274},[271,2207,2208],{"class":400},"# 发版前防御 checklist (每次发版前在 build host 做完整新用户回归)\n",[271,2210,2211],{"class":273,"line":280},[271,2212,2213],{"class":400},"# 3. dist 内 grep C:\\Users\\ 应该 0 命中（防 G25 类绝对路径泄露）\n",[271,2215,2216,2219,2222,2224,2227,2230,2232],{"class":273,"line":286},[271,2217,2218],{"class":350},"ssh",[271,2220,2221],{"class":346}," CHANCHING@",[271,2223,1065],{"class":342},[271,2225,2226],{"class":346},"构建",[271,2228,2229],{"class":338},"机",[271,2231,2001],{"class":342},[271,2233,2234],{"class":346}," 'powershell -Command \"\n",[271,2236,2237],{"class":273,"line":293},[271,2238,2239],{"class":346},"  Get-ChildItem D:\\wakou-build\\dist-X.Y.Z\\WakouPanelPortable -Recurse -File -Include *.json,*.jsonl |\n",[271,2241,2242],{"class":273,"line":299},[271,2243,2244],{"class":346},"    Where-Object { $_.Length -lt 200KB } |\n",[271,2246,2247],{"class":273,"line":464},[271,2248,2249],{"class":346},"    Select-String C:\\\\Users\\\\Administrator -SimpleMatch -List |\n",[271,2251,2252],{"class":273,"line":469},[271,2253,2254],{"class":346},"    Select-Object FullName\n",[271,2256,2257],{"class":273,"line":475},[271,2258,2259],{"class":346},"\"'\n",[10,2261,2262],{},"任何一项 fail → 不要 GA，修了再 bump 一个 patch 版本。",[10,2264,2265,2266,2269,2270,2273,2274,2277],{},"Connector OTA skill 的故障字典（§4）则是一张病症与病因的对照表。\"signature verification failed\" 对应的处置是检查 ",[14,2267,2268],{},"TAURI_SIGNING_PRIVATE_KEY"," 是不是 .sec 文件",[46,2271,2272],{},"base64 编码后","的内容。\"OTA 不跨级但用户期望直跳\" 的处置是调整 ",[14,2275,2276],{},"findNextVersion"," 逻辑或在灰度模式放上一个 manifest。",[10,2279,2280,2281,2284],{},"关键在",[46,2282,2283],{},"处置是写死在 skill 里","，不是留在事后复盘让人去翻。OpenClaw Runtime 发版 skill 的故障字典甚至铺了 10 条常见陷阱：BuildKit context 缓存不清导致改动没编进去、gwbridge IP 池满导致 service 无法起、孤儿 docker-proxy 占着 host port、磁盘满导致 openclaw.json 被写成 0 字节。",[10,2286,2287],{},"每一条都带着：\"现象是什么、根因是什么、怎么修\"。拿 BuildKit 缓存那条：",[2289,2290,2291],"blockquote",{},[10,2292,2293,2294,2297,2298,2301,2302,2304],{},"症状：rsync\u002FSFTP 把新源推上 prod repo，源文件 grep 确认是新的，但 docker build 出来的镜像里还是旧 bundle。根因：",[14,2295,2296],{},"--no-cache-filter"," 不可靠，BuildKit 的 build context 层仍可能命中缓存。修法：clawpanel 源改动后，必须用整体 ",[14,2299,2300],{},"--no-cache","，不要用 ",[14,2303,2296],{},"。代价：单次 ~20-30 分钟。但这是唯一能确保改动真编进去的方式。",[10,2306,2307,2308,2311],{},"这不是\"高级技巧\"或\"经验之谈\"。这是",[46,2309,2310],{},"发版的必读须知","，写进 skill 里才能确保每次都不遗漏。",[19,2313,2314],{"id":2314},"为什么这样做有效",[10,2316,2317],{},"高风险操作的安全性来自流程的确定性，不来自执行者的谨慎。人会忘、会打错、会跳步。但固化的流程不会：",[151,2319,2320,2323,2326],{},[154,2321,2322],{},"触发词明确化杀死了\"我不是故意调用发版\"的那类误操作。",[154,2324,2325],{},"步骤验证链条把每一步的前置条件和验收标准写成 Bash 检查，没法绕过。",[154,2327,2328],{},"故障处置内联把一个多月里积累的坑提前封死在流程里，新一轮发版不会重蹈覆辙。",[10,2330,2331],{},"5 个 skill 现在覆盖了：Wakou panel OTA、Connector 桌面端 OTA、OpenClaw runtime 容器全量发布、Codex 助手静默更新、加上客服远程调试的风险边界。它们没有一个是让 AI 执行发版的——都是让 AI 陪着用户，一步步走完一个铺满护栏的流程。",[10,2333,2334],{},"这是把\"不要让 AI 碰发版\"这条禁区，变成了\"AI 执行发版的框架，确保每一步都验证、每个坑都已知、每种故障都有救法\"的可行路径。",[578,2336,2337],{},"html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":38,"searchDepth":280,"depth":280,"links":2339},[2340,2341,2342,2343],{"id":1875,"depth":280,"text":1876},{"id":1918,"depth":280,"text":1919},{"id":2103,"depth":280,"text":2104},{"id":2314,"depth":280,"text":2314},"2026-06-16",{},"\u002F2026-06-16-skill",{"title":1867,"description":1872},"2026-06-16-把高风险发版固化成skill","发版是明确不交给 AI 自主执行的操作。通过触发词精确化、步骤验证链条、故障处置内联，把流程固化到 skill，让高风险操作失败概率趋零。",[609,605,2351,2352,2353],"自动化","Tauri","Docker Swarm","WbjJuf61wSEx4d5Iov2KhPdG1N6hik51RAl8rAGgug8",1789212573556]