diff --git a/.github/workflows/sync-upstream.yml b/.github/workflows/sync-upstream.yml index 92a316fca02d..542855181812 100644 --- a/.github/workflows/sync-upstream.yml +++ b/.github/workflows/sync-upstream.yml @@ -155,3 +155,13 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" echo "::warning title=REQ-012 anchor tripwire::upstream sync broke reskin anchor contract — visual re-verify required before any release" fi + + # S39 复盘病灶 2(2026-07-12):ADR-020 预警过「冻结前端 ↔ 新引擎的运行时契约漂移, + # typecheck 测不全,真机冒烟兜底」——此前冒烟从未机械化,sync 后引擎行为变化只能等真机撞见 + # (REQ-083 复验实锤:同一验收脚本 7-10 绿、合 sync 后 stage5 僵死)。本步无头启动合并后的 + # 引擎 → 打关键端点 → 硬杀 → 同端口重启 → 再打(respawn 族覆盖)。失败 = 本 run 红: + # 引擎运行时冒烟破,发布/继续开发前必须人工排查(merge 已推送——红的意义是高声报警)。 + - name: Engine runtime smoke (S39 — boot/probe/kill/reboot) + run: | + bun install + bash scripts/engine-smoke.sh diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b4c5413b1339..64d23fc558a4 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,9 @@ ## [Unreleased] +### Fixed +- **模型选择框不再「已配置的供应商凭空消失 + 代理模型全灰」**(REQ-083,需下个签名版本生效)——引擎重启的间隙(存 Key、登录、激活代理都会触发引擎重启)打开模型选择框,此前会静默渲染成一个假象:已配置 Key 的 DeepSeek/智谱整体不可见、代理模型明明是会员也全部灰锁,而且点灰行还会再触发一次引擎重启,越点越坏。现在:①连不上引擎时如实显示「正在连接引擎…稍后自动恢复」,已配置的供应商保留占位行(「已配置 · 模型加载中…」),绝不消失;②弹窗自动重试,引擎一恢复列表就地补全,无需重开;③引擎不可达时点灰行不再触发重启(只有「引擎在线且代理确实未激活」才会激活代理)。真机断言:两类断网场景(拦截取数/真杀引擎)点灰行全程零重启。 + ### Added - **新增出厂技能「cloud-dispatch」:会话里就能把任务派到云上跑**(REQ-082,需下个签名版本生效)——登录(代付)状态下,让 agent「云端调研一下 X」即可正确派发:技能教会模型云任务的完整契约(调研/代码审查/文档三类管线与沙箱档、预算上限、结果取回),并内置数据边界纪律(代码只送 diff、绝不带密钥/.env)。未登录或 BYOK 时如实告知云派发不可用并指路登录,不会假装派发。 - **新增出厂技能「office-docs」:做表格/写文档/生成 PPT 有了正确指路**(REQ-080,需下个签名版本生效)——问「帮我做个 Excel」时模型知道该用哪个连接器(读文档=markitdown、写 xlsx/docx/pptx=对应办公连接器)、没装时指路扩展市场一键安装(或征得同意后用本地 Python 脚本兜底)、内置表格制作惯例(真实数字/日期类型、表头冻结、公式而非写死数值)与 PDF 生成代码片段(生态没有可信的 PDF 写连接器,如实补位不硬装)。 diff --git a/docs/audits/2026-07-10-s39-req083/01-baseline.png b/docs/audits/2026-07-10-s39-req083/01-baseline.png new file mode 100644 index 000000000000..33898fbd075b Binary files /dev/null and b/docs/audits/2026-07-10-s39-req083/01-baseline.png differ diff --git a/docs/audits/2026-07-10-s39-req083/02-outage-honest-state.png b/docs/audits/2026-07-10-s39-req083/02-outage-honest-state.png new file mode 100644 index 000000000000..809b94d0affb Binary files /dev/null and b/docs/audits/2026-07-10-s39-req083/02-outage-honest-state.png differ diff --git a/docs/audits/2026-07-10-s39-req083/03-self-healed-same-popup.png b/docs/audits/2026-07-10-s39-req083/03-self-healed-same-popup.png new file mode 100644 index 000000000000..20a5587590fb Binary files /dev/null and b/docs/audits/2026-07-10-s39-req083/03-self-healed-same-popup.png differ diff --git a/docs/audits/2026-07-10-s39-req083/04-real-outage-honest-state.png b/docs/audits/2026-07-10-s39-req083/04-real-outage-honest-state.png new file mode 100644 index 000000000000..b817c277b17c Binary files /dev/null and b/docs/audits/2026-07-10-s39-req083/04-real-outage-honest-state.png differ diff --git a/docs/audits/2026-07-10-s39-req083/05-restored-baseline.png b/docs/audits/2026-07-10-s39-req083/05-restored-baseline.png new file mode 100644 index 000000000000..25983faf8f85 Binary files /dev/null and b/docs/audits/2026-07-10-s39-req083/05-restored-baseline.png differ diff --git a/docs/audits/2026-07-10-s39-req083/verify.md b/docs/audits/2026-07-10-s39-req083/verify.md new file mode 100644 index 000000000000..f7176c791c78 --- /dev/null +++ b/docs/audits/2026-07-10-s39-req083/verify.md @@ -0,0 +1,65 @@ +# REQ-083 真机验证 — 模型选择框 respawn 竞态修复(2026-07-10,S39) + +> 环境:dev 构建(vite 5173 非陈旧 bundle,`location.href` 已核)+ dev 渠道 userData(真实登录态 PRO + 已配置 deepseek/zhipuai 双 Key)。驱动 = CDP 9222(dev 默认开),脚本断言 + 截图双证。 + +## 根因复盘(报障当日日志定案) + +用户报障(v0.1.2 打包真机,10:37–10:42):已配置 Key 的 DeepSeek/智谱在选择框整体消失;登录 PRO 后代理模型全灰。日志证据链: + +1. 5 分钟内 5 次 sidecar respawn(存 Key ×2 / 登出 / 登录 / 点灰行 ×N —— `enableProxy` 无条件 respawn),每次对应 renderer 一波 `Failed to fetch`(main.log + renderer.log 时间戳逐一对齐); +2. ModelPickPop 弹窗打开只拉一次 `config.providers` 且 `.catch(()=>{})` 静默吞 → engineModels 空 → ① configured BYOK 两头落空(不进「需 KEY」区、又无模型行)= 整体不可见;② `proxyLive()` 假 → 代理 13 行全 locked;账户横幅走 main IPC 不受影响,仍显示「PRO 会员」= 矛盾画面; +3. member/balance 态点 locked 行无条件 `window.api.auth.enableProxy()` → 又一次 respawn + renderer reload = **自续循环**(10:42:38.441 上一次 respawn 完成的同一毫秒排入下一次,实锤)。 + +**排除项(实证)**:非端点域名切换(`alpha-gateway.tidelabs.click` HTTP 200、`ALPHA_BASE_URL` 正确、白名单同步 `byok: unrestricted`);非数据丢失(干净重启后 CDP 直查引擎:deepseek 4 模型 / zhipuai 13 模型 / alpha 13 模型全在,弹窗渲染全对)。 + +## 修复(三点) + +1. **取数诚实 + 自愈**:load 状态机(`engineReady`/`engineStalled`)+ 退避重试(1s/2s/4s/8s 封顶,弹窗存活期间持续;sdk 未就绪同样可重试);非 ok 态顶置「正在连接引擎…」note + configured BYOK 占位行(`已配置 · 模型加载中…`,disabled)。健康路径首拉 ~ms 级不闪占位(stalled 门)。 +2. **点灰行不再火上浇油**:`lockedPickAction` 纯函数(login/recharge/activate/none)——仅「引擎在线(engineReady)且代理节点确实缺席(!proxyLive)」才 activate;引擎不可达一律 none。`enableProxy` 唯一调用点即此,main 侧无需改动。 +3. **respawn 后自动恢复**:重试循环覆盖(弹窗不重开就地补全);key 保存后 3s 补拉保留(失败进同一重试循环)。 + +## 断言结果(scripts: scratchpad/verify-req083.ts,全 PASS) + +| # | 场景 | 断言 | 结果 | +|---|---|---|---| +| 0 | 基线(引擎健康) | 弹窗 30 行可选 0 locked,BYOK 模型行 19(deepseek+glm),无 note | PASS(01-baseline.png) | +| 1 | 拦截 `/config/providers`(staging 复现 Failed to fetch) | note 出现 + DeepSeek/智谱占位行 ×2 + 代理 13 行 locked | PASS(02-outage-honest-state.png) | +| 2 | 故障态点 locked 代理行 ×1 | 页面未 reload(marker 存活)、弹窗仍开、**零 respawn** | PASS | +| 3 | 解除拦截 | **同一弹窗不重开**自动恢复:note 消失、BYOK 19 行回来、0 locked | PASS(03-self-healed-same-popup.png) | +| 4 | 真死服务器(`killSidecar`)重复 1+2 | 同 1/2 全过 | PASS(04-real-outage-honest-state.png) | +| 5 | `retrySidecar` 恢复 | respawn+reload(预期行为)后基线复核:0 locked / 19 BYOK / 无 note | PASS(05-restored-baseline.png) | + +**日志复核**:整场验证 main.log 仅 1 次 `respawning sidecar (proxy activation)` = stage 5 主动恢复;stage 2/4 两次故障态点灰行 **零 respawn**(修复前每点一次触发一次)。 + +## Gates + +- alpha-check 全绿(北极星守卫 + typecheck + 单测 673/673,新增 model-picker-logic 6 例) +- 零改上游文件(全部改动落 `packages/ui-mac` 自有文件) +- UI PR 用户亲验门:dev 已留可用态(登录 PRO + 双 BYOK),待用户 GO 后合并 + +--- + +## 2026-07-12 复验(用户委托真机验收)—— FAIL → 补丁 → 全绿 + +**复跑结果**:stage 0-4 全 PASS;**stage 5(retrySidecar 恢复)FAIL** —— 暴露第四种故障形态「悬挂」: +respawned 引擎无鉴权请求秒回 401(中间件活着),带鉴权 `/config/providers` **永不返回**(捕获请求挂起数分钟 +无 resolve/reject;新发 6s 超时请求 TimeoutError)。原修复只覆盖 reject,未覆盖 hang → fetch 既不成功也不 +失败 → 状态机进不了 stalled → 无 note 无占位、13 行全灰、BYOK 消失、不自愈(用户当场撞见并截图)。 + +**补丁(同日)**: +1. `model-picker-logic.ts` 新增 `ENGINE_FETCH_TIMEOUT_MS = 10_000` + 单测 2 例(超时 > 一切退避间隔;取值窗口); +2. `alpha-composer-model.tsx` 取数带 `AbortSignal.timeout` —— 悬挂转成可重试失败进既有重试循环,abort 同时 + 关闭底层连接(防 Chromium 连接池复用死套接字); +3. 验收 harness 固化进仓 `packages/ui-mac/scripts/verify-picker-respawn.ts`(S39 复盘病灶 1):新增 **hang 注入 + 阶段**(尊重 abort signal 的永不返回 fetch)+ 恢复阶段改**收敛轮询**(修 7-10 脚本定时快照把引擎暖机误判 + 为回归的缺陷)+ 断言硬失败退出非零; +4. `scripts/engine-smoke.sh` + sync-upstream.yml 新步(S39 复盘病灶 2):每日 sync 后无头启动引擎 → 打 + `/config/providers` → 硬杀 → 同端口重启 → 再打,红 = 引擎运行时冒烟破(本地实跑绿)。 + +**复验(补丁后,harness 全量)**:14/14 断言 PASS —— 基线 30 可选/19 BYOK;reject 故障态诚实 + 点灰行零 +respawn/reload + 同弹窗自愈;**hang 故障态超时后出诚实 note + 占位(修复盲区闭合)+ 解除后自愈**;真杀 +sidecar 同全过;retrySidecar 恢复后收敛回满基线。证据:`docs/audits/2026-07-12-s39-req083-reverify/`(7 png)。 + +**残余(单独立项追踪,不阻断本 REQ)**:引擎 respawn 后悬挂为**非确定性竞态**(6 个 kill→retry 循环 1 现), +判决实验(僵死瞬间带鉴权外部 curl 分辨引擎僵死 vs renderer 连接池僵死)未能复现完成;UI 层超时补丁使其 +用户面退化为「诚实提示 + 自动恢复」,不再是静默全灰。 diff --git a/docs/audits/2026-07-12-s39-req083-reverify/01-baseline.png b/docs/audits/2026-07-12-s39-req083-reverify/01-baseline.png new file mode 100644 index 000000000000..a25d999c39ef Binary files /dev/null and b/docs/audits/2026-07-12-s39-req083-reverify/01-baseline.png differ diff --git a/docs/audits/2026-07-12-s39-req083-reverify/02-reject-outage.png b/docs/audits/2026-07-12-s39-req083-reverify/02-reject-outage.png new file mode 100644 index 000000000000..3e742f72a779 Binary files /dev/null and b/docs/audits/2026-07-12-s39-req083-reverify/02-reject-outage.png differ diff --git a/docs/audits/2026-07-12-s39-req083-reverify/03-self-healed.png b/docs/audits/2026-07-12-s39-req083-reverify/03-self-healed.png new file mode 100644 index 000000000000..1d4645ae691a Binary files /dev/null and b/docs/audits/2026-07-12-s39-req083-reverify/03-self-healed.png differ diff --git a/docs/audits/2026-07-12-s39-req083-reverify/04-hang-outage-honest.png b/docs/audits/2026-07-12-s39-req083-reverify/04-hang-outage-honest.png new file mode 100644 index 000000000000..d7a6b9916a67 Binary files /dev/null and b/docs/audits/2026-07-12-s39-req083-reverify/04-hang-outage-honest.png differ diff --git a/docs/audits/2026-07-12-s39-req083-reverify/05-hang-self-healed.png b/docs/audits/2026-07-12-s39-req083-reverify/05-hang-self-healed.png new file mode 100644 index 000000000000..1d4645ae691a Binary files /dev/null and b/docs/audits/2026-07-12-s39-req083-reverify/05-hang-self-healed.png differ diff --git a/docs/audits/2026-07-12-s39-req083-reverify/06-real-outage-honest.png b/docs/audits/2026-07-12-s39-req083-reverify/06-real-outage-honest.png new file mode 100644 index 000000000000..5d34a4055d6e Binary files /dev/null and b/docs/audits/2026-07-12-s39-req083-reverify/06-real-outage-honest.png differ diff --git a/docs/audits/2026-07-12-s39-req083-reverify/07-restored-baseline.png b/docs/audits/2026-07-12-s39-req083-reverify/07-restored-baseline.png new file mode 100644 index 000000000000..c6154876d956 Binary files /dev/null and b/docs/audits/2026-07-12-s39-req083-reverify/07-restored-baseline.png differ diff --git a/docs/sprints/2026-07-10-s39-req083-picker-respawn-race/sprint.md b/docs/sprints/2026-07-10-s39-req083-picker-respawn-race/sprint.md new file mode 100644 index 000000000000..547a43d89aeb --- /dev/null +++ b/docs/sprints/2026-07-10-s39-req083-picker-respawn-race/sprint.md @@ -0,0 +1,36 @@ +# S39 — REQ-083 模型选择框 respawn 竞态修复(2026-07-10) + +> 契约(ADR-018):目标 / 抽取 IDs / task 表 / gates / 结果 / 回写清单。 + +## 目标 + +修掉模型选择框在 sidecar respawn 窗口内的三个叠加缺陷:取数静默失败(BYOK 消失 + 代理全灰)、点灰行无条件触发 respawn(自续循环)、respawn 后无自愈。根因复盘见 BACKLOG REQ-083 行内(用户报障 2026-07-10,v0.1.2 真机日志定案)。 + +## 抽取 + +| ID | 状态入 | 状态出 | +|---|---|---| +| REQ-083 | ready | shipped(PR #173) | + +## Tasks + +- [x] T1 取数诚实 + 自愈:ModelPickPop 引擎模型表改 load 状态机(`engineReady`/`engineStalled`)+ 退避重试(sdk 未就绪/fetch 失败均可重试,弹窗存活期间自动恢复);stalled 态顶置「正在连接引擎」note,已配置 BYOK 供应商渲染占位行(不再整体消失,反 C28 placebo;健康路径 ~ms 级首拉不闪占位) +- [x] T2 点灰行不再火上浇油:locked 行点击动作抽纯函数 `lockedPickAction`(login/recharge/activate/none)——member/balance 态仅当**引擎在线且代理节点确实缺席**才调 `enableProxy()`;引擎不可达一律 no-op,杜绝「点灰行 → respawn → reload → 再点」循环(`enableProxy` 唯一调用点在此,main 侧零改动) +- [x] T3 respawn 后自动恢复:T1 重试循环覆盖(弹窗不需重开,实测同一弹窗就地补全);key 保存后 3s 补拉保留 +- [x] 单测:`model-picker-logic.test.ts` 6 例(lockedPickAction 全分支 + 退避节奏);全量 673 全绿 +- [x] 真机验证(dev + CDP,证据 [audits/s39](../../audits/2026-07-10-s39-req083/verify.md)):① 拦截取数 + 真杀 sidecar 两场景 = note + BYOK 占位 ×2 + 点灰行**零 respawn**(main.log 复核全场仅 1 次主动恢复 respawn);② 解除故障 → 同一弹窗不重开自动恢复(19 BYOK 行 + 0 locked);③ 基线不回归(30 行可选) +- [x] **T4(2026-07-12 委托验收发现的盲区)悬挂转可重试失败**:`ENGINE_FETCH_TIMEOUT_MS` + 取数带 `AbortSignal.timeout`(hang → 超时 → stalled note + 重试;abort 关闭死连接)+ 单测 2 例 +- [x] **T5 验收 harness 固化进仓**(复盘病灶 1):`packages/ui-mac/scripts/verify-picker-respawn.ts`,新增 hang 注入阶段 + 恢复收敛轮询 + 断言硬失败;复验 14/14 PASS(证据 [audits/2026-07-12-reverify](../../audits/2026-07-12-s39-req083-reverify/)) +- [x] **T6 引擎冒烟机械化**(复盘病灶 2):`scripts/engine-smoke.sh` + sync-upstream.yml 新步(boot → probe → kill → reboot → probe,本地实跑绿) + +## Gates + +- [x] alpha-check(北极星守卫 + typecheck + 单测)全绿 +- [x] 零改上游文件 +- [x] UI PR 用户亲验门(训示 2026-07-09):用户 2026-07-12 **委托验收** —— 复验发现 stage5 FAIL(悬挂盲区,用户当场撞见)→ 同日补丁 → harness 全量 14/14 PASS → 用户指示「将 S39 处理掉」= GO 收口。残余(引擎 respawn 悬挂竞态,非确定性 1/6)单独立项追踪,UI 面已由超时补丁兜底 + +## 回写清单 + +- [x] BACKLOG REQ-083 → shipped(PR #173) +- [x] CHANGELOG [Unreleased] 用户可见条目 +- [x] 证据:audits/2026-07-10-s39-req083/(5 png + verify.md) diff --git a/packages/ui-mac/scripts/verify-picker-respawn.ts b/packages/ui-mac/scripts/verify-picker-respawn.ts new file mode 100644 index 000000000000..ae9426b491ab --- /dev/null +++ b/packages/ui-mac/scripts/verify-picker-respawn.ts @@ -0,0 +1,181 @@ +// REQ-083 模型选择框 respawn 竞态 —— 可重复真机验收 harness(2026-07-12 从会话 scratchpad 固化进仓)。 +// +// 为什么在仓库里:真机验证 PASS 只对当天有效;脚本进仓 + 断言硬失败,后续任何改动(上游 sync / +// renderer 重构)都能低成本复跑,不再依赖某次会话的临时产物(S39 复盘病灶 1)。 +// +// 用法: +// 1. `bun run --cwd packages/ui-mac dev` 起 dev 实例(CDP 9222 默认开),登录态 + 至少一个已配置 BYOK +// 2. `bun packages/ui-mac/scripts/verify-picker-respawn.ts [截图输出目录]` +// 任一断言 FAIL → 退出码 1。会真杀/重启 sidecar,勿在别人正用的实例上跑(破坏性测试先告知,病灶 5)。 +// +// 阶段(异步取数四态全覆盖:成功 / 失败 / 悬挂 / 恢复): +// 0 基线健康 → 1 注入 reject(Failed to fetch)→ 2 故障态点灰行零 respawn/reload → +// 3 解除 → 同一弹窗自愈 → 4 注入「悬挂」(响应永不来,尊重 abort signal)→ 超时转 stalled +// note → 解除 → 自愈 → 5 真杀 sidecar 重复 1+2 → 6 retrySidecar 恢复 → 收敛回基线(轮询,非定时快照)。 + +const AUDIT = process.argv[2] ?? `/tmp/req083-verify-${Date.now()}` +await Bun.$`mkdir -p ${AUDIT}`.quiet() +let failures = 0 +const assert = (name: string, ok: boolean, detail?: unknown) => { + console.log(` assert ${name}: ${ok ? "PASS" : "FAIL"}${detail !== undefined ? " " + JSON.stringify(detail) : ""}`) + if (!ok) failures++ +} + +const targets = await (await fetch("http://127.0.0.1:9222/json")).json() +const page = targets.find((t: any) => t.type === "page") +if (!page) throw new Error("no CDP page target — is the dev app running?") +const ws = new WebSocket(page.webSocketDebuggerUrl) +let id = 0 +const pending = new Map void>() +ws.onmessage = (ev) => { + const msg = JSON.parse(String(ev.data)) + if (msg.id && pending.has(msg.id)) { + pending.get(msg.id)!(msg.result) + pending.delete(msg.id) + } +} +const send = (method: string, params: any = {}) => + new Promise((resolve) => { + const i = ++id + pending.set(i, resolve) + ws.send(JSON.stringify({ id: i, method, params })) + }) +await new Promise((r) => (ws.onopen = r)) +const evalJs = async (expression: string) => { + const r = await send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true }) + if (r?.exceptionDetails) throw new Error("page eval failed: " + JSON.stringify(r.exceptionDetails).slice(0, 300)) + return r?.result?.value +} +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) +const shot = async (name: string) => { + const s = await send("Page.captureScreenshot", { format: "png" }) + if (s?.data) await Bun.write(`${AUDIT}/${name}.png`, Buffer.from(s.data, "base64")) + console.log(` 📸 ${name}.png`) +} +const openPicker = async () => { + await evalJs(`document.querySelector(".a-mpp") || document.querySelector(".a-chip-model")?.click()`) + await sleep(600) +} +const closePicker = async () => { + await evalJs(`document.querySelector(".a-mpp") && document.querySelector(".a-chip-model")?.click()`) + await sleep(300) +} +const pickerState = () => + evalJs(`(() => { + const pop = document.querySelector(".a-mpp") + if (!pop) return { open: false } + const items = Array.from(pop.querySelectorAll(".a-pop-item")) + return { + open: true, + note: Array.from(pop.querySelectorAll(".a-pop-note")).map((n) => n.textContent.trim().slice(0, 30)), + locked: items.filter((el) => el.className.includes("locked")).length, + unlocked: items.filter((el) => el.className.includes("a-mpp-row") && !el.className.includes("locked") && !el.className.includes("needkey") && !el.className.includes("pending") && !el.className.includes("addrow")).length, + pendingRows: items.filter((el) => el.className.includes("a-mpp-pending")).length, + byokModels: items.filter((el) => /deepseek-|glm-/.test(el.textContent) && (el.textContent.includes("DeepSeek") || el.textContent.includes("智谱"))).length, + } + })()`) +const waitFor = async (pred: (st: any) => boolean, seconds: number) => { + for (let i = 0; i < seconds; i++) { + await sleep(1000) + const st = await pickerState() + if (pred(st)) return st + } + return await pickerState() +} +// 故障注入器:mode = "off" | "reject" | "hang"。hang 尊重 abort signal(如实模拟真 fetch 被 +// AbortSignal.timeout 中止),否则测不到「超时转可重试失败」这条链。 +const installInjector = () => + evalJs(`(() => { + if (window.__req083_mode !== undefined) return "already" + const of = window.fetch.bind(window) + window.__req083_mode = "off" + window.fetch = (...a) => { + const u = String(a[0] instanceof Request ? a[0].url : a[0]) + if (u.includes("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/config/providers")) { + if (window.__req083_mode === "reject") return Promise.reject(new TypeError("Failed to fetch (staged outage)")) + if (window.__req083_mode === "hang") + return new Promise((_, rej) => { + const s = a[1]?.signal ?? (a[0] instanceof Request ? a[0].signal : undefined) + if (s) s.addEventListener("abort", () => rej(s.reason ?? new DOMException("aborted", "AbortError")), { once: true }) + }) + } + return of(...a) + } + return "installed" + })()`) +const setMode = (m: string) => evalJs(`(window.__req083_mode = "${m}")`) + +console.log("== stage 0: baseline (healthy engine) ==") +// app 冷启动:等 composer(.a-chip-model)挂载完再开测,否则首次 openPicker 点空 +for (let i = 0; i < 30; i++) { + if (await evalJs(`!!document.querySelector(".a-chip-model")`)) break + await sleep(1000) +} +await evalJs(`window.__req083_marker = "alive"`) +await installInjector() +await openPicker() +let st = await waitFor((s) => s.open && s.unlocked > 0 && s.byokModels > 0, 15) +console.log(JSON.stringify(st)) +assert("baseline: rows selectable + BYOK visible + no note", st.open && st.unlocked > 0 && st.byokModels > 0 && st.note.length === 0, st) +await shot("01-baseline") + +console.log("== stage 1: staged reject outage ==") +await closePicker() +await setMode("reject") +await openPicker() +st = await waitFor((s) => s.note?.length > 0, 8) +assert("reject: honest note", st.note.length > 0, st.note) +assert("reject: configured-BYOK placeholder rows", st.pendingRows >= 2, st.pendingRows) +assert("reject: proxy rows locked", st.locked > 0, st.locked) +await shot("02-reject-outage") + +console.log("== stage 2: click locked row during outage — zero respawn/reload ==") +await evalJs(`document.querySelector(".a-mpp .a-mpp-row.locked")?.click()`) +await sleep(3000) +assert("locked click: page not reloaded", (await evalJs(`window.__req083_marker`)) === "alive") +assert("locked click: popup still open", (await pickerState()).open) + +console.log("== stage 3: lift reject — same-popup self-heal ==") +await setMode("off") +st = await waitFor((s) => s.open && s.byokModels > 0 && s.note.length === 0, 12) +assert("self-heal without reopening", st.open && st.byokModels > 0 && st.note.length === 0, st) +await shot("03-self-healed") + +console.log("== stage 4: HANG outage (2026-07-12 blind spot) — timeout must convert to stalled+retry ==") +await closePicker() +await setMode("hang") +await openPicker() +// ENGINE_FETCH_TIMEOUT_MS(10s)+ 首个退避 1s + 余量:note 必须在 ~15s 内出现 +st = await waitFor((s) => s.note?.length > 0, 16) +assert("hang: honest note appears after timeout (not silent-gray)", st.note.length > 0, st) +assert("hang: configured-BYOK placeholder rows", st.pendingRows >= 2, st.pendingRows) +await shot("04-hang-outage-honest") +await setMode("off") +st = await waitFor((s) => s.open && s.byokModels > 0 && s.note.length === 0, 25) +assert("hang lifted: same-popup self-heal", st.open && st.byokModels > 0 && st.note.length === 0, st) +await shot("05-hang-self-healed") + +console.log("== stage 5: real dead server (killSidecar) ==") +await closePicker() +await evalJs(`window.api.killSidecar()`) +await sleep(1500) +await openPicker() +st = await waitFor((s) => s.note?.length > 0, 16) +assert("real outage: honest note + placeholders", st.note.length > 0 && st.pendingRows >= 2, st) +await evalJs(`document.querySelector(".a-mpp .a-mpp-row.locked")?.click()`) +await sleep(3000) +assert("real outage: locked click did not respawn/reload", (await evalJs(`window.__req083_marker`)) === "alive") +await shot("06-real-outage-honest") + +console.log("== stage 6: restore (retrySidecar → respawn+reload expected) — converge to baseline ==") +await evalJs(`window.api.retrySidecar()`) +await sleep(9000) // respawn + renderer reload(injector/marker 随 reload 消失,属预期) +await evalJs(`document.querySelector(".a-chip-model")?.click()`) +// 收敛轮询而非定时快照:respawn 后引擎暖机时长不定(2026-07-12 复盘:定时快照会把暖机误判为回归) +st = await waitFor((s) => s.open && s.unlocked > 0 && s.byokModels > 0 && s.note.length === 0, 45) +assert("restored baseline (rows selectable + BYOK back)", st.open && st.unlocked > 0 && st.byokModels > 0 && st.note.length === 0, st) +await shot("07-restored-baseline") + +ws.close() +console.log(failures === 0 ? "\n✅ all assertions PASS" : `\n❌ ${failures} assertion(s) FAILED`) +process.exit(failures === 0 ? 0 : 1) diff --git a/packages/ui-mac/src/renderer/alpha-ui/alpha-composer-model.tsx b/packages/ui-mac/src/renderer/alpha-ui/alpha-composer-model.tsx index 9dac8db785a2..6824f5ef5cfa 100644 --- a/packages/ui-mac/src/renderer/alpha-ui/alpha-composer-model.tsx +++ b/packages/ui-mac/src/renderer/alpha-ui/alpha-composer-model.tsx @@ -5,12 +5,13 @@ // 引擎实际注册的模型),选择写入 composer-state(localStorage 持久),提交时作 SDK model 参数。 // 账户横幅/锁定语义与 model-picker-inject 保持一字不差(同一设计稿)。 -import { createMemo, createSignal, For, onMount, Show } from "solid-js" +import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js" import type { AccountSummary, AuthState } from "../../preload/types" import type { EffectiveCatalog, ProviderKeyStatus, Tier } from "../../shared/alpha-model-types" import { ALPHA_PATHS } from "../../shared/alpha-config" import { useAlphaEndpoints } from "../use-alpha-endpoints" import { composerModelSuspended, setComposerModel, type ComposerModel, type SuspendReason } from "./composer-state" +import { ENGINE_FETCH_TIMEOUT_MS, lockedPickAction, nextEngineRetryDelay } from "./model-picker-logic" import type { AlphaProjectsApi } from "../sidebar/use-projects" import { AddProvider } from "./model-picker-add" @@ -31,6 +32,9 @@ type Row = { /** catalog BYOK 供应商但未配置 KEY:如实显示,点击进配置表单(与旧 picker 同语义; * 此前整行隐藏 = 用户「BYOK 消失了」报障 2026-07-07 的直接根因之一)。 */ needKey?: boolean + /** 已配置 KEY 但引擎模型表尚未拉到(respawn 窗口)的占位行:如实存在、不可点(REQ-083; + * 此前整体消失 = 用户「BYOK 又消失了」报障 2026-07-10 的直接根因之一)。 */ + pending?: boolean } export function ModelPickPop(props: { sdk: AlphaProjectsApi["sdk"]; onPicked: () => void }) { @@ -40,31 +44,62 @@ export function ModelPickPop(props: { sdk: AlphaProjectsApi["sdk"]; onPicked: () const [summaryError, setSummaryError] = createSignal(null) const [keyStatus, setKeyStatus] = createSignal({}) const [engineModels, setEngineModels] = createSignal>([]) + /** 引擎模型表至少成功拉到过一次。false = respawn 窗口/引导中,UI 走诚实占位而非「消失/全灰」(REQ-083)。 */ + const [engineReady, setEngineReady] = createSignal(false) + /** 至少失败过一次且尚未恢复 —— note/占位行的显示门(健康路径首拉 ~ms 级,不闪占位)。 */ + const [engineStalled, setEngineStalled] = createSignal(false) const [query, setQuery] = createSignal("") const [addOpen, setAddOpen] = createSignal(false) const [configureId, setConfigureId] = createSignal(null) const endpoints = useAlphaEndpoints() + // REQ-083:拉取失败不再静默吞掉 —— 退避重试直到弹窗卸载(sidecar respawn ~秒级,弹窗内自愈)。 + let retryTimer: ReturnType | undefined + let retryAttempt = 0 + let disposed = false + onCleanup(() => { + disposed = true + clearTimeout(retryTimer) + }) + const scheduleEngineRetry = () => { + if (disposed) return + setEngineStalled(true) + clearTimeout(retryTimer) + retryTimer = setTimeout(loadEngineModels, nextEngineRetryDelay(retryAttempt++)) + } const loadEngineModels = () => { + if (disposed) return const c = props.sdk() - if (c) - void c.config - .providers({} as any) - .then(({ data }: any) => { - const out: Array<{ providerID: string; modelID: string }> = [] - const provs = Array.isArray(data?.providers) ? data.providers : Array.isArray(data) ? data : [] - for (const p of provs) { - const pid = p?.id ?? p?.providerID - const models = p?.models && typeof p.models === "object" ? Object.keys(p.models) : [] - for (const mid of models) out.push({ providerID: pid, modelID: mid }) - } - setEngineModels(out) - }) - .catch(() => {}) + if (!c) { + // renderer 刚被 respawn reload 时 SDK 客户端可能尚未就绪 —— 视作可重试,不放弃。 + scheduleEngineRetry() + return + } + void c.config + // 悬挂(连接被接受但响应永不来)必须转成可重试失败,否则状态机进不了 stalled(REQ-083 复验盲区) + .providers({} as any, { signal: AbortSignal.timeout(ENGINE_FETCH_TIMEOUT_MS) } as any) + .then(({ data, error }: any) => { + const provs = Array.isArray(data?.providers) ? data.providers : Array.isArray(data) ? data : null + if (error || !provs) { + scheduleEngineRetry() + return + } + const out: Array<{ providerID: string; modelID: string }> = [] + for (const p of provs) { + const pid = p?.id ?? p?.providerID + const models = p?.models && typeof p.models === "object" ? Object.keys(p.models) : [] + for (const mid of models) out.push({ providerID: pid, modelID: mid }) + } + setEngineModels(out) + setEngineReady(true) + setEngineStalled(false) + retryAttempt = 0 + }) + .catch(() => scheduleEngineRetry()) } const refreshKeys = () => { window.api.providers.keyStatus().then(setKeyStatus).catch(() => {}) - // 改键触发 sidecar respawn,引擎模型表随后变化 —— 延迟再拉一次收敛(best-effort)。 + // 改键触发 sidecar respawn,引擎模型表随后变化 —— 延迟再拉一次收敛(失败进重试循环)。 loadEngineModels() setTimeout(loadEngineModels, 3000) } @@ -139,6 +174,26 @@ export function ModelPickPop(props: { sdk: AlphaProjectsApi["sdk"]; onPicked: () .filter((r) => matchQ(r.model.name, r.sub)) }) + /* REQ-083:已配置 KEY 但引擎模型表还没拉到(respawn 窗口/引导中)→ 占位行如实存在。 + * 此前直接消失:configured 被排除出「需 KEY」区,模型行又依赖空的引擎表 —— 两头落空。 */ + const pendingByokRows = createMemo(() => { + if (engineReady() || !engineStalled()) return [] + const cat = catalog() + if (!cat) return [] + return cat.byokProviders + .filter((p) => keyStatus()[p.id]?.configured ?? false) + .map( + (p): Row => ({ + model: { providerID: p.id, modelID: "", name: p.name, variants: [] }, + sub: "已配置 · 模型加载中…", + pico: p.pico, + reasoning: false, + pending: true, + }), + ) + .filter((r) => matchQ(r.model.name, r.sub)) + }) + /* catalog BYOK 供应商、KEY 未配置 → 如实显示为「需 KEY」行(点击进配置表单)。 * 旧 picker(model-picker-inject)一直是这个语义;新 picker 曾整行隐藏 —— 用户填过 KEY 的 * DeepSeek 在换 userData(渠道分裂)后"凭空消失",无从发现也无从修复。 */ @@ -160,15 +215,19 @@ export function ModelPickPop(props: { sdk: AlphaProjectsApi["sdk"]; onPicked: () }) const pick = (r: Row) => { + if (r.pending) return // 占位行:引擎连上后自动变成真模型行,点了不该有副作用 if (r.needKey) { setConfigureId(r.model.providerID) setAddOpen(true) return } if (r.locked) { - if (state() === "out") void window.api.auth.start() - else if (state() === "empty") window.api.openLink(`${endpoints().web}${ALPHA_PATHS.wallet}?tab=recharge`) - else void window.api.auth.enableProxy() + // REQ-083:只有「引擎在线且代理节点确实缺席」才触发 enableProxy(respawn 是修复); + // 引擎不可达时 respawn 只会扩大故障面(点灰行 → 重启 → renderer reload → 再点 的自续循环)。 + const action = lockedPickAction(state(), engineReady(), proxyLive()) + if (action === "login") void window.api.auth.start() + else if (action === "recharge") window.api.openLink(`${endpoints().web}${ALPHA_PATHS.wallet}?tab=recharge`) + else if (action === "activate") void window.api.auth.enableProxy() return } setComposerModel(r.model) @@ -213,6 +272,10 @@ export function ModelPickPop(props: { sdk: AlphaProjectsApi["sdk"]; onPicked: () 上次使用的「{composerModelSuspended()!.model.name}」{suspendText(composerModelSuspended()!.reason)};恢复后自动还原,也可直接改选其他模型。 + {/* REQ-083:引擎模型表未就绪(sidecar 重启窗口)→ 如实说明 + 自动重试,不静默渲染成「全灰 + BYOK 消失」。 */} + +
正在连接引擎(可能正在重启)…模型列表稍后自动恢复,无需重开此窗口。
+
{/* REQ-069:未登录不逐条外显海外 model id(用户拍板 2026-07-08)—— 平台区收敛为登录引导卡 (品牌可提、id 不列);登录后恢复全量清单(锁定语义不变)。 */} @@ -250,7 +313,7 @@ export function ModelPickPop(props: { sdk: AlphaProjectsApi["sdk"]; onPicked: () - +
直连 · 自带 KEY
{(r) => ( @@ -263,6 +326,17 @@ export function ModelPickPop(props: { sdk: AlphaProjectsApi["sdk"]; onPicked: () )} + + {(r) => ( + + )} + {(r) => (
diff --git a/packages/ui-mac/src/renderer/alpha-ui/alpha-composer.css b/packages/ui-mac/src/renderer/alpha-ui/alpha-composer.css index 77c7c6452fb0..58d022d7e18a 100644 --- a/packages/ui-mac/src/renderer/alpha-ui/alpha-composer.css +++ b/packages/ui-mac/src/renderer/alpha-ui/alpha-composer.css @@ -276,6 +276,11 @@ body[data-alpha-composer-takeover] [data-component="session-composer"] { .a-mpp-needkey { opacity: 0.75; } +/* REQ-083:已配置 KEY、引擎重启窗口内的占位行(如实存在、不可点,引擎连上自动换真模型行) */ +.a-mpp-pending { + opacity: 0.55; + cursor: default; +} .a-mpp-addrow { border-top: 1px solid var(--a-border-faint); margin-top: 4px; diff --git a/packages/ui-mac/src/renderer/alpha-ui/model-picker-logic.test.ts b/packages/ui-mac/src/renderer/alpha-ui/model-picker-logic.test.ts new file mode 100644 index 000000000000..f1a346bc2906 --- /dev/null +++ b/packages/ui-mac/src/renderer/alpha-ui/model-picker-logic.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test" +import { ENGINE_FETCH_TIMEOUT_MS, lockedPickAction, nextEngineRetryDelay } from "./model-picker-logic" + +describe("lockedPickAction (REQ-083)", () => { + test("登出态永远引导登录(与引擎状态无关)", () => { + expect(lockedPickAction("out", true, false)).toBe("login") + expect(lockedPickAction("out", false, false)).toBe("login") + }) + + test("余额不足永远引导充值(与引擎状态无关)", () => { + expect(lockedPickAction("empty", true, false)).toBe("recharge") + expect(lockedPickAction("empty", false, true)).toBe("recharge") + }) + + test("会员/余额态 + 引擎在线 + 代理缺席 → activate(genuine 激活,respawn 是修复)", () => { + expect(lockedPickAction("member", true, false)).toBe("activate") + expect(lockedPickAction("balance", true, false)).toBe("activate") + expect(lockedPickAction("error", true, false)).toBe("activate") + }) + + test("引擎不可达时一律 none —— 杜绝「点灰行 → respawn → 再点」自续循环(REQ-083 根因)", () => { + expect(lockedPickAction("member", false, false)).toBe("none") + expect(lockedPickAction("balance", false, false)).toBe("none") + expect(lockedPickAction("error", false, false)).toBe("none") + }) + + test("代理已在线则无事可做(此时行本不该 locked)", () => { + expect(lockedPickAction("member", true, true)).toBe("none") + expect(lockedPickAction("balance", true, true)).toBe("none") + }) +}) + +describe("nextEngineRetryDelay", () => { + test("退避节奏 1s/2s/4s/8s 封顶", () => { + expect(nextEngineRetryDelay(0)).toBe(1000) + expect(nextEngineRetryDelay(1)).toBe(2000) + expect(nextEngineRetryDelay(2)).toBe(4000) + expect(nextEngineRetryDelay(3)).toBe(8000) + expect(nextEngineRetryDelay(10)).toBe(8000) + }) +}) + +describe("ENGINE_FETCH_TIMEOUT_MS(2026-07-12 复验盲区:悬挂必须转成可重试失败)", () => { + test("超时必须严格大于任何退避间隔 —— 保证「悬挂 → 超时 → 退避 → 重试」链不重叠不空转", () => { + for (const attempt of [0, 1, 2, 3, 10]) { + expect(ENGINE_FETCH_TIMEOUT_MS).toBeGreaterThan(nextEngineRetryDelay(attempt)) + } + }) + + test("取值在诚实窗口内:远大于健康路径首拉(ms 级),不超过用户可忍受的提示延迟上限", () => { + expect(ENGINE_FETCH_TIMEOUT_MS).toBeGreaterThanOrEqual(3_000) + expect(ENGINE_FETCH_TIMEOUT_MS).toBeLessThanOrEqual(30_000) + }) +}) diff --git a/packages/ui-mac/src/renderer/alpha-ui/model-picker-logic.ts b/packages/ui-mac/src/renderer/alpha-ui/model-picker-logic.ts new file mode 100644 index 000000000000..8c57acaf64e6 --- /dev/null +++ b/packages/ui-mac/src/renderer/alpha-ui/model-picker-logic.ts @@ -0,0 +1,36 @@ +// REQ-083:模型选择框 respawn 竞态的纯决策核(单测覆盖,组件侧只做接线)。 +// +// 背景(2026-07-10 真机日志定案):sidecar respawn 窗口内,弹窗一次性拉取引擎模型表失败被 +// 静默吞掉 → BYOK 已配置供应商整体消失 + 代理行全 locked;此时 member/balance 态点 locked 行 +// 会无条件 enableProxy() → 又一次 respawn + renderer reload,形成「点灰行 → 重启 → 再点」的 +// 自续循环。这里把两件事抽成纯函数: +// 1. lockedPickAction —— locked 行点击该干什么。关键裁决:只有「引擎在线(engineReady)且 +// 代理节点确实缺席(!proxyLive)」才允许 activate(respawn 是修复);引擎不可达时一律 +// none —— respawn 只会扩大故障面。 +// 2. nextEngineRetryDelay —— 引擎模型表拉取失败后的退避重试节奏(弹窗存活期间自愈)。 + +export type AccountState = "member" | "balance" | "empty" | "out" | "error" + +export type LockedPickAction = "login" | "recharge" | "activate" | "none" + +export function lockedPickAction(state: AccountState, engineReady: boolean, proxyLive: boolean): LockedPickAction { + if (state === "out") return "login" + if (state === "empty") return "recharge" + if (engineReady && !proxyLive) return "activate" + return "none" +} + +/** 0-based 尝试次数 → 1s / 2s / 4s / 8s 封顶(弹窗短命,持续重试直到卸载)。 */ +export function nextEngineRetryDelay(attempt: number): number { + return Math.min(8000, 1000 * 2 ** Math.min(attempt, 3)) +} + +/** + * 引擎取数硬超时(ms)。2026-07-12 真机验收发现第四种故障形态:请求「悬挂」—— + * 连接被接受但响应永不到达(respawn 竞态下 1/5 复现),fetch 既不 resolve 也不 reject, + * 状态机进不了 stalled → 全灰假象且不自愈。异步取数四态 = 成功/失败/悬挂/中途作废, + * 悬挂必须由超时转成可重试失败(AbortSignal.timeout → catch → scheduleEngineRetry), + * abort 同时会关闭底层连接,避免 Chromium 连接池继续复用死套接字。 + * 取值:远大于健康路径首拉(~ms 级),小到用户可忍受的「诚实提示」延迟上限。 + */ +export const ENGINE_FETCH_TIMEOUT_MS = 10_000 diff --git a/scripts/engine-smoke.sh b/scripts/engine-smoke.sh new file mode 100644 index 000000000000..ba036eb02832 --- /dev/null +++ b/scripts/engine-smoke.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# engine-smoke — 引擎运行时最小冒烟(S39 复盘病灶 2:ADR-020 预警的「运行时契约漂移, +# typecheck 测不全」此前无机械兜底;每日 sync 后引擎行为变化只能等真机撞见)。 +# +# 做三件事:① 无头启动合并后的引擎;② 打关键端点(/config/providers,弹窗/composer 的 +# 取数面);③ 硬杀后同端口重启再打一遍(respawn 族覆盖 —— REQ-083 verified 当天 stage5 +# 曾在 sync 后的引擎上僵死)。任一步失败 → 退出非零(sync 工作流红 = 引擎运行时冒烟破, +# 发布前必须人工排查;见 sync-upstream.yml 对应 step)。 +# +# 本地:bash scripts/engine-smoke.sh(需已 bun install) +set -uo pipefail +cd "$(git rev-parse --show-toplevel)" +PORT="${ENGINE_SMOKE_PORT:-4573}" +LOG="${TMPDIR:-/tmp}/engine-smoke.log" +ENGINE_PID="" + +cleanup() { [ -n "$ENGINE_PID" ] && kill -9 "$ENGINE_PID" 2>/dev/null; wait "$ENGINE_PID" 2>/dev/null || true; } +trap cleanup EXIT + +probe() { # $1 = label;15s 超时 —— 悬挂(连接被接受但响应不来)与失败同判(异步四态纪律) + local code + code="$(curl -s -m 15 -o /dev/null -w '%{http_code}' "http://127.0.0.1:$PORT/config/providers" || echo "curl:$?")" + if [ "$code" != "200" ]; then + echo " ✗ $1: /config/providers → $code" + tail -25 "$LOG" 2>/dev/null + return 1 + fi + echo " ✓ $1: /config/providers 200" +} + +boot() { # $1 = label + bun run packages/opencode/src/index.ts serve --port "$PORT" --hostname 127.0.0.1 >"$LOG" 2>&1 & + ENGINE_PID=$! + for _ in $(seq 1 60); do + if curl -s -m 2 -o /dev/null "http://127.0.0.1:$PORT/config/providers"; then return 0; fi + if ! kill -0 "$ENGINE_PID" 2>/dev/null; then + echo " ✗ $1: engine process died during boot" + tail -25 "$LOG" + return 1 + fi + sleep 1 + done + echo " ✗ $1: engine not reachable within 60s" + tail -25 "$LOG" + return 1 +} + +echo "▶ engine smoke: boot #1" +boot "boot#1" || exit 1 +probe "boot#1" || exit 1 + +echo "▶ hard-kill engine (respawn-family coverage)" +kill -9 "$ENGINE_PID" +wait "$ENGINE_PID" 2>/dev/null || true +ENGINE_PID="" +sleep 1 + +echo "▶ engine smoke: boot #2 (same port, post-kill)" +boot "boot#2" || exit 1 +probe "boot#2" || exit 1 + +echo "✅ engine smoke green"