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