HEL-902 unified offline review: integration branch verified (pytest 376/376 + chain 12/12 + req checks 4/4), fault-injection suite for register/session/journal half-states, frontend preservation re-verified 8/8 + author logs consistent, one must-fix (services wording) + five suggestions consolidated, real-test approval package prepared (caps stay 0 until approved), requirements README baseline-line erratum v1.11->v1.12

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
总工
2026-10-28 00:37:37 +08:00
co-authored by multica-agent
parent d167ba927c
commit b09a907747
5 changed files with 334 additions and 1 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# CyberGril 需求入口
当前完整基准:**v1.11**。批准需求继承办法的主卡评论:`01a1635b-b47c-79ed-ac66-9408c7905af9`。
当前完整基准:**v1.12**(HEL-899 起 v1.12;本行此前长期写 v1.11,属入口勘误,基准与变更记录本身自 v1.12 起即为准确,未回退)。批准需求继承办法的主卡评论:`01a1635b-b47c-79ed-ac66-9408c7905af9`。
所有新任务、续办、设计、施工、审核先读完整当前需求,再读本次相关来源。只有白栖知明确修改的要求才可变更,阶段范围不能覆盖产品要求。
+27
View File
@@ -0,0 +1,27 @@
{
"baseline_version": "1.12",
"read_commit": "d167ba927c1b07e4771219707c744e90051837d9",
"task": "HEL-902:统一离线审核 HEL-900 前端(hel-900-preserved @ 701442d/37b4e69)与 HEL-899 后台(ce7279e),整合 HEL-879 真实窗口修复(hel-879-assembly @ be149cd)到独立分支 hel-902-integration,零真实调用、零部署、零依赖下载;并备齐真机补测申请包",
"requirement_ids": ["CG-006", "CG-009", "CG-010", "CG-013", "CG-014", "CG-015", "CG-016", "CG-018", "CG-019", "CG-020", "CG-021", "VIS-003", "VIS-004", "VIS-008", "VIS-010", "UX-003", "UX-006", "AM-001", "GOV-001", "GOV-002", "GOV-003", "GOV-004", "GOV-005", "GOV-006", "DEL-001"],
"deliverable": "独立整合分支 hel-902-integration(701442d+be149cd 无冲突合并,不改写母线/旧现场/PR#11);总工统一审核报告 docs/rework/hel902/REVIEW.md(含后台故障注入新增 tests/test_voice_review_hel902.py 6 用例:本地元数据落盘失败可恢复不逼重复注册、响应断连重试留可回收孤儿条目、开房 worker 写失败诚实 503、worker 状态损坏行为如实记录、流水写不进时应用 fail-closed 409、选择写失败整体回滚);真机补测申请包 docs/rework/hel902/r5-followup-package.md(时窗/逐类上限/停机方法/参考音频来源与 cg- 共享新增回收/旧服务保护/HEL-879 遗留量测并入);需求入口 README 基准行 v1.11→v1.12 勘误(授权非视觉小修,基准不回退)",
"acceptance": [
"统一 head 上实际复跑:pytest 376/376(370 基线+新增6)、verify_hel899_custom_voice.py 12/12、ruff src/tests/scripts 通过、需求检查 4/4(origin/main 与 ce7279e 各带/不带 handoff-HEL-900)",
"前端保全独立复核:8 文件 blob SHA-256 与保全清单 8/8 一致;作者 ZIP 重下校验 SHA-256 一致;36 截图全部有效 PNG 且视口与 harness 定义一致;作者日志(vitest 137/137、lint 0错1既有警告、build、浏览器 83/83)与 handoff 声明逐项相符",
"前端逐行静态复核与合同 §2/用户原话 01a17de1 一致:原始 WAV 字节+查询串、浏览器真转 16k mono PCM16、大小时长静音拒绝、allow_asr 显式门、试听必合法 WAV+canplay 后才可应用、参考原声与现场合成分开、请求代际清理、录音轨迹释放、演示入口不挂载零请求、CSS 仅用既有 token",
"后台重点补核:capture→写 worker 文件→dispatch→worker 入口读一次的顺序锁定;共享注册表仅经 8090 且 cg- 命名空间不碰 11 支内置与通用助手状态;ASR/试听流水仅核算、硬闸仅 LLM/嵌入(如实,未称硬闸)",
"问题清单集中一轮:🔴外接服务文案按实际能力改写(交视觉作者);🟡登记本地落盘失败映射文案、孤儿条目说明、worker 状态 docstring、会话页显示本次音色(结论:需要)、capture-dispatch 串行化;承接 CG-009/R6 删除与备份、R5 整体验收、R1–R7 完整功能"
],
"deferred": [
"真实登记/可选 ASR 转写/真实试听/应用与下一通话验证、克隆相似度、真机(iPhone/Android/Safari)录音路径:待主卡批准补测包后执行,未批前真实 cap=0",
"HEL-879 遗留:打断后约1秒停声毫秒级量测、文字打断语音方向、真实浏览器/设备麦克风路径——并入同一补测窗口,量测方法已写入补测包",
"前端独立复跑(tsc/eslint/vitest/build/浏览器两主题×电脑/手机、演示入口零外发断言):本机依赖被清理且禁下载,需一次性 npm 同锁文件安装授权或随真机窗口在 .12 执行",
"自定义音色删除、完整备份/恢复(CG-009/R6)后续卡;R5 整体验收与 R1–R7 完整功能持续承接;PR#4/PR#8 等仍无合并或替换正式界面授权"
],
"requirement_changes": [],
"verification": [
"pytest tests -q 376/376(py3.11.16 复用既有审核 venv,零安装)",
"python -I scripts/verify_hel899_custom_voice.py 12/12;ruff check src tests scripts 通过",
"python -I scripts/check_requirements.py --base-ref origin/main(及 --handoff docs/requirements/handoff-HEL-900.json、ce7279e 对照)4/4 通过",
"前端与浏览器未在本机复跑(环境缺依赖且本卡禁下载安装),以作者日志+逐行静态复核+保全哈希复核作证据,如实标注未验证范围;真机项全部列入补测包"
]
}
+72
View File
@@ -0,0 +1,72 @@
# HEL-902 统一审核报告:自定义声音前后台(离线)
审核人:总工。2026-10-27/28。零真实 LLM/嵌入/ASR/TTS 调用、零共享音色写入、零部署、零依赖下载安装。
## 1. 实际统一 head 与整合范围
- 整合分支 `hel-902-integration`,merge head `d167ba9`(本卡推送):
- `origin/hel-900-preserved` @ `37b4e69`(作者源码 `701442d` 直接以 HEL-899 后台 `ce7279e` 为父);
- merge `origin/hel-879-assembly` @ `be149cd`(真实窗口修复:LiveKit 服务端 token 携带 SDK 对等 video grants、Twirp JSON、自签证书材料与窗口证据),无冲突,未改写任何母线/旧现场/PR#11。
- 前端保全独立复核:`docs/rework/hel900/preservation/preservation.json` 八文件 Git blob SHA-256 与工作树逐文件比对 **8/8 一致**;作者附件 ZIP 重新下载校验 SHA-256 `15879ce6…d74fe` 与保全记录一致。36 张截图全部为有效 PNG(22 张 1440×900、14 张 390×844@2x),命名与 harness 视口定义一致。
## 2. 实际运行(本机可离线证明的部分)
| 项 | 结果 |
| --- | --- |
| 全量后端 `pytest tests -q`(py3.11.16,既有审核 venv 复用,零安装) | **376/376 通过**(370 基线 + 本卡新增故障注入 6) |
| `scripts/verify_hel899_custom_voice.py`(真实统一 app + fake 面板/TTS/LiveKit 全链) | **12/12 通过**(本机重跑) |
| `ruff check src tests scripts` | 通过 |
| 需求检查 4 组(origin/main 与 ce7279e,各带/不带 handoff-HEL-900) | 4/4 通过(本机重跑) |
| 总工新增故障注入 `tests/test_voice_review_hel902.py` | 6/6 通过(见 §4) |
**本机无法复跑、如实声明**:前端 `tsc --noEmit`/`eslint`/`vitest 137`/`vite build` 与浏览器 83 项本运行环境没有 web 依赖(node_modules 全部被清理,npm 本地缓存不完整,`npm ci --offline` 因缺包失败),而本卡禁止依赖下载安装。作者的 137/83/构建/lint 记录在 `docs/rework/hel900/preservation/author-logs/` 完整在库且与 handoff 声明一致(vitest 137/137、lint 0 错 1 既有警告、build 337.92kB、浏览器 83/83 PASS);本卡对这些只作证据采信 + 逐行代码静态复核(§3),不冒充复跑。前端复跑与两主题×电脑/手机浏览器实测列入补测包的可选预审项(需一次性依赖安装授权)与真机窗口项。
## 3. 代码复核结论(逐项对合同 §2 与用户原话 01a17de1)
前端 `web/src/voice/custom.ts` / `VoiceSection.tsx` / `ManagePanel.tsx` / `components.css`(作者原件):
- 上传=原始 WAV 字节+查询串、浏览器 `OfflineAudioContext@16k` 真解码重采→16k mono PCM16、≥2s/静音/15MB/解不开拒绝、`allow_asr` 仅「文字空且用户显式开开关」为 true、试听响应必须合法 WAV、canplay 后才可应用、参考原声与现场合成分开标注、每类请求独立 AbortController+代际守卫、objectURL 全回收、录音仅点击开启/只取音轨/停止取消离开即释放/20s 自动停/迟到授权即关、登记中重复点击不再发且不中止已发上传、演示入口不挂载组件零请求(`real && open` 条件,静态核实)——**与合同一致**。
- CSS 新增 44 行仅用既有 token(`--accent/--dur-ui/--field…`),无硬编码色(两处中性白透明度除外,双主题安全)。
- 验收措辞勘误(非缺陷):「改扩展名的非 WAV 在浏览器被拒」仅对**解不开**的内容成立;可解开的音频(含改名 WebM/MP3)按合同设计**转成真 WAV 后上传**,mock 端核验的正是产物 WAV 头。
后端 `src/cybergril/voice/custom.py` / `router.py` / `session.py` / `app.py`(ce7279e)+ livekit_wire(hel-879-assembly 修复):
- 会话开启先 `_capture_voice`(选择有效→选择;否则 worker 文件现值;再无效→default;从未选择不代写)写 worker 文件**后**才 `create_dispatch`,worker 会话入口读一次;通话中改选最迟下一通话生效、绝不半句换人(离线链 12/12 含 pin/next-call 用例)。
- 共享注册表只经 8090:`cg-` 服务端生成 8 hex、撞名换新、既有 11 支与通用助手状态零写入零删除(测试断言)。
- 试听未知音色前置拦截(TTS 零调用,防静默回落 default);试听零写聊天/记忆/回执。
## 4. 后台重点补核(故障注入,总工新增,全部假服务)
| 场景 | 实际行为 | 判定 |
| --- | --- | --- |
| 8090 登记成功、本地元数据落盘失败 | 面板已登记;未捕获 OSError→**HTTP 500**(未映射合同错误文案);但进程内记录仍在可试听可应用,重启后元数据丢、共享注册表仍在→声音仍列出可用(label=ID),**可恢复、不逼重复注册** | 🟡 文案/映射建议修 |
| 上传在面板侧已生效但响应断连→重试 | 503 诚实拒绝;重试另立新 cg- ID,共享注册表留下**无元数据孤儿条目**(列表可见 label=ID、可正常使用;既有 11 支与通用状态零改写) | 🟡 孤儿回收走 8090 自有删除,登记失败提示可加「已登记的半条会以编号显示」说明 |
| 通话开启 worker 音色写不进 | 503「写不进」、不开房、选择不受牵连、恢复即通 | ✅ |
| worker 文件可读→其他键保留;损坏(非 JSON)→整体重写仅剩 voice | 与 docstring「保留其他键」在损坏场景不符 | 🟡 注释/极端行为说明 |
| 试听流水写不进 | 试听仍成功(流水尽力而为),应用凭据缺失→**409 fail-closed**,守门不放松 | ✅(方向正确) |
| 选择状态落盘失败 | 整体回滚、worker 文件不动、503 如实 | ✅ |
| ASR/试听流水与限额 | 流水仅**核算**(有界 5000→2000 行);硬闸只有 LLM/嵌入(`think/budget.py` acquire),试听/登记/开房无代码级上限 | ✅ 如实;补测包按 ops 上限申报 |
| `_capture_voice` 写文件与 worker 实际读文件之间的窗口 | 用户恰在开房瞬间「应用」会落进本通话(worker 仍整通话单一音色,无半句换人;仅「最迟下一通话」表述在该秒级窗口内不成立) | 🟡 残余竞态,单用户设置面板实际难触发;可在 `custom._lock` 内串行化 capture→dispatch 根除 |
## 5. 集中问题清单(交有限返修/承接,不另起多轮)
**🔴 必须修(视觉作者一轮内,或经授权的必要小修)**
1. 「外接服务」分区仍写语音识别/语音合成「尚未接通」,与同面板「她的声音」分区矛盾(HEL-878 起语音链路已在真实窗口验证)。要求按实际能力如实说明:建议随 `/api/voice/status` 动态显示(如「语音合成:已接入她的声音 · 这会儿服务器状态 X」),不得一律改「已接通」,也不得维持「尚未接通」。位置 `web/src/manage/ManagePanel.tsx:190`。
**🟡 建议优化(同轮顺手或后续小卡)**
2. 登记成功但本地元数据落盘失败→500:把 OSError 映射为 503/422 中文文案,并说明「已登记的半条会以编号出现在列表里」。`custom.py register()`。
3. 登记响应断连重试产生的共享孤儿 cg- 条目:在列表/文档标注「无元数据条目=未完成登记,可用或经 8090 面板删除」;补测包含回收步骤。
4. `write_worker_voice` docstring「保留其他键」补一句「文件读不出时整体重写」的实况。
5. 会话页显示「本次通话声音」(`POST /api/voice/session` 响应已含 `voice` 字段,前端 `SessionGrant` 未取):建议开房后在通话条上显示本次音色并与「下一通生效」文案区分。结论:**需要**,小改。
6. `_capture_voice`→worker 读文件的秒级窗口竞态:用 `custom._lock` 把 capture+dispatch 串行化即可根除(后端小改)。
**承接保留(不悄删)**:自定义音色删除与完整备份/恢复(CG-009/R6 后续卡);R5 整体真机验收;R1–R7 完整功能;真实克隆相似度与真机(iPhone/Android/Safari)录音路径未测;演示入口零外发的浏览器复验。
## 6. 需求与勘误
- 需求 v1.12/60 项核对:CG-010 边界已按用户原话 `01a17de1` 更新(自定义音色为准),原话证据 `sources/comment-evidence-custom-voice-20261027.json` 逐字一致;CG-009 备份恢复等其余条文未被触动。
- 勘误(本卡完成,属授权的非视觉小修):`docs/requirements/README.md` 首行基准 v1.11→**v1.12**(current/baseline/changes 早已 v1.12),勘误说明写入该行;基准未回退,4 组需求检查复跑通过。
## 7. 结论
**通过(离线审核)+ 一轮有限返修**:后台 376/376 与离线全链 12/12 在统一整合 head 上实际复跑通过;前端作者证据完整且经逐行静态复核与合同比对一致,1 项必须修(外接服务文案)与 5 项建议优化集中交视觉作者/后续小卡;真实试听/转写/克隆相似度/真机麦克风与前端复跑全部列入补测包(`r5-followup-package.md`),未获批准前真实 cap=0。不部署、不合并、不替换正式界面。
+55
View File
@@ -0,0 +1,55 @@
# HEL-902 补测包:自定义声音真机窗口申请材料(交总管集中请批)
状态:**材料备齐,未获批前真实 cap=0**。所有上限为**新的一次性窗口授权**,不复用、不重开 HEL-879 已封账(其 14/40 请求、42717/500000 tokens、12/200 TTS 段保持封存;剩余数≠新许可)。工程参数由工程定,用户只批准真实用量与设备操作。
## 1. 统一 head 与部署范围
- 候选整合分支 `hel-902-integration` @ `d167ba9`(hel-900-preserved 701442d + hel-879-assembly be149cd,无冲突)。离线证据见 `docs/rework/hel902/REVIEW.md`:后端 376/376、全链 12/12、ruff/需求检查 4/4。
- 部署形态沿用 HEL-877/879 runbook:隔离双入口 8360/8361 + 8363 WSS 桥 + 专属 worker 8082 + 共享 TTS 9880(窗口内临时恢复,窗口后停回);`config/voice.yaml` 按合同 §4 增补。**不触碰** 8300–8350 演示/真实档案、8090(只读调用其 API)、8081、7880、旧资料与冻结账、4080;ComfyUI 保持暂停;不合 PR、不替换正式界面。
- 回滚:不部署即无痕;已部署时 git 还原 + 停窗口进程 + 移除 voice.yaml 增补即回 HEL-877 行为;资料空间三个新文件留存无害。
## 2. 测试目标与逐步动作(全部可观察、可中止)
1. **真实登记**:浏览器(桌面 Chrome/Edge,LAN 机)上传一段 5–20 秒 WAV 参考音频 → `cg-` 登记 → 列表出现。
2. **可选 ASR**(≤3 次):留空参考文字、显式开转写开关 → FunASR 转写回显;另做一次「不开开关且无文字→422 拒绝」负例。
3. **真实试听**(≤10 次合成):现场合成 WAV 浏览器播放;负例:TTS 停机→503。
4. **应用与下一通验证**(≤3 次通话):试听后应用 → 开通话核对 worker 实际音色=会话响应 `voice` 字段;通话中改选→本通话不换声、下一通生效;从未选择→不代写 worker 文件。
5. **HEL-879 遗留量测**(并入同一窗口,不另开窗):
- 打断后约 1 秒停声:真实通话开口打断,以 worker 日志 `turn_cancel` 时间戳与最后 TTS 段发送时间差为准(毫秒级,附日志行);辅以浏览器侧听到停声即停表的人工秒表复核。
- 文字打断语音方向:她语音输出中发送文字→声即停、文字被处理;与既有「语音取消文字」方向互补。
- 真实浏览器/设备麦克风路径:LAN 机真实麦克风(非 fake device)完成第 4 项;如用户愿意,iPhone Safari 经 8363 WSS + 证书信任步骤试一连(可选,失败不影响其余结论)。
6. **窗口内快检**(可选,若获批一次性依赖安装授权):本机 `npm ci`(同锁文件)→ 复跑 vitest 137/tsc/eslint/build + `hel900_mock.mjs`+`hel900_shots.mjs`(Chromium headless,两主题×电脑/手机 + 演示入口零外发断言)。不获批则此项留待后续。
## 3. 参考音频来源与共享注册表新增/回收
- 来源:优先用总管自备的一段真实人声 WAV(用户已授权「音色你自己决定」适用窗口测试音色);或复用 D1 试听包中已只读取回的既有参考录音之一作克隆源(不新增采集、不新录用户声音、不申请摄像头)。
- 共享 `voices.json` 新增:仅 `cg-` 命名空间新条目(服务端生成,结构上不可能碰既有 11 支;面板仍是唯一写者)。**回收**:窗口结束按登记清单逐条经 8090 面板自有删除入口删除并截图/列表复核;保留一条经用户后续确认去留。元数据/流水在实例资料空间,随实例清理。
- 全局默认音色、通用助手 `voice_state.json`/`persona_state.json` 不触碰(测试断言已含)。
## 4. 时窗与逐类上限(一次性,超出即停)
| 类别 | 上限 | 计数口径 |
| --- | --- | --- |
| 时窗 | ≤2 小时(建议 1 小时) | 首个真实请求起算 |
| 语音通话 | ≤3 次 | POST /api/voice/session 成功开房 |
| 开房(含空会话) | ≤6 次 | 同上,含失败重试 |
| LLM 请求 | ≤20 | 统一账本 acquire(失败/打断/后台都计) |
| LLM tokens | ≤60000 | 同上 |
| 嵌入 | 0 | 硬闸 |
| 登记转写 ASR | ≤3 | `voice-usage.jsonl` register_asr 行 |
| 试听合成 | ≤10 | preview_tts 行 |
| 通话 TTS 段 | ≤40 | worker 流水 |
| 音色登记 | ≤3 | 8090 upload 成功数 |
停机方法:任一上限达到即停(LLM/嵌入为代码硬闸,其余为工程守则:实时盯 `voice-usage.jsonl` 与统一账本页面);窗口结束停 8360/8361/8082/8363/9880(9880 停回原状态),`ss -ltn` 复核端口、房间经 API 挂断或 TTL 清理,账本与流水快照存档到 `docs/rework/hel902/`,孤儿 cg- 条目按 §3 回收。
## 5. 旧服务与资料保护清单(窗口内不变式)
8300/8310 演示与真实档案进程不动;8090 只读调用+授权后的 cg- 写入;7880/8081 不碰;既有数据库、身份、聊天、记忆不重置;测试写入只落隔离实例数据空间;每日自动备份任务(如有)不中断;不动队员模型/运行时/认证;不申请摄像头。
## 6. 通过判据
- 登记→(可选转写)→试听→应用→下一通话用新声音,全链真实完成且日志对得上;通话中改选不中途换声。
- 打断停声时延有毫秒级日志证据;文字打断语音方向有观察记录。
- 共享注册表前后 diff 仅 `cg-` 新增且回收完成;11 支内置与全局默认零变化(前后快照比对)。
- 全部上限内完成;结束后无残留窗口进程/端口/房间。
+179
View File
@@ -0,0 +1,179 @@
"""HEL-902 统一审核:后台重点故障注入补核(总工独立新增).
针对总管列出的统一审核重点,用可控假服务注入故障,零真实调用:
1. 8090 已登记、CyberGril 本地元数据落盘失败 —— 是否留下无法恢复或重复注册的半状态。
2. 登记上传成功后面板响应断连(结果已生效、响应丢失)+ 用户重试 —— 共享注册表里出现什么。
3. 通话开启时 worker 音色状态写不进 —— 诚实拒绝且不开房。
4. worker 状态文件可读时其他键保留;文件损坏(读不出 JSON)时其他键是否被丢弃。
5. 试听流水写不进 —— 「应用前有可听试听」凭据缺失时应用是否守门(fail-closed)。
6. 选择状态落盘失败 —— 整体回滚,worker 文件不被写。
时间与真实性边界:worker 实际在会话入口读音色文件、与真实 worker 的时序一致性
属真机窗口验证范围(见 docs/rework/hel902/),本文件只锁 API 侧可离线证明的事实。
"""
import json
import httpx
import pytest
from test_voice_custom import ( # noqa: F401 (复用作者既有桩;同目录根式导入)
BUILTIN,
CREATE,
FakeVoiceStack,
build_app,
make_wav,
)
from cybergril.voice.custom import CustomVoiceService
def _register(client, **params):
return client.post("/api/voice/voices", params={"prompt_text": "参考文字", **params},
content=make_wav(3), headers={"Content-Type": "audio/wav"})
@pytest.fixture
def stack(tmp_path):
return FakeVoiceStack(tmp_path)
@pytest.fixture
def instance(tmp_path, stack):
"""同作者桩,但 raise_server_exceptions=False:故障路径要按产品语义看 500."""
from fastapi.testclient import TestClient
app, custom, worker_state = build_app(tmp_path, stack)
with TestClient(app, raise_server_exceptions=False) as client:
client.post("/api/companion", json=CREATE)
yield client, stack, custom, worker_state, tmp_path
def _swap_panel(custom, stack, handler):
transport = httpx.MockTransport(handler)
stack.transport = transport
custom.http = httpx.AsyncClient(transport=transport, verify=False)
def test_panel_ok_local_metadata_write_fails(instance):
"""8090 登记成功、本地元数据写不进:500(未映射合同错误);状态仍可恢复、不逼重复注册."""
client, stack, custom, worker_state, tmp_path = instance
blocked = tmp_path / "blocked-custom.json"
blocked.mkdir()
custom.registry_path = blocked
response = _register(client)
assert response.status_code == 500 # OSError 未被映射为 503/422(产品语义)
assert len(stack.uploads) == 1
voice_id = stack.uploads[0]["name"]
# 面板已登记;进程内记录仍在:本进程里看得到、可试听可应用,不是死条目
listing = client.get("/api/voice/voices").json()
assert voice_id in {v["id"] for v in listing["voices"]}
assert client.post("/api/voice/preview", json={"voice": voice_id}).status_code == 200
assert client.post("/api/voice/selection", json={"voice": voice_id}).status_code == 200
# 「重启」后元数据确实丢失(无 label/时间),但共享注册表仍在,声音仍可列出使用
revived = CustomVoiceService(
config=custom.config, http=custom.http,
selection_path=tmp_path / "sel.json", registry_path=tmp_path / "custom2.json",
journal_path=tmp_path / "usage.jsonl")
assert voice_id in set(stack.ids)
assert voice_id not in revived.custom_voices() # 元数据丢了,声音没丢
def test_upload_response_lost_then_retry_leaves_orphan(instance):
"""上传在面板侧已生效但响应断连 → 503;重试另立新 ID,共享注册表留下无元数据孤儿条目."""
client, stack, custom, worker_state, tmp_path = instance
original = stack._handle
def flaky(request: httpx.Request) -> httpx.Response:
if request.url.host == "panel.test" and request.url.path == "/api/voices/upload":
original(request) # 面板真的登记了(写进共享注册表与上传账)
raise httpx.ConnectError("response lost after panel committed")
return original(request)
_swap_panel(custom, stack, flaky)
first = _register(client)
assert first.status_code == 503 # 诚实说「登记服务不在」,但面板侧已成立
committed = [u["name"] for u in stack.uploads]
assert len(committed) == 1
_swap_panel(custom, stack, original) # 用户看到失败后重试
second = _register(client)
assert second.status_code == 201
orphan, live = committed[0], second.json()["id"]
registry = json.loads(stack.registry_file.read_text())
assert orphan in registry and live in registry
listing = {v["id"]: v for v in client.get("/api/voice/voices").json()["voices"]}
assert orphan in listing and listing[orphan]["label"] == orphan # 孤儿以 ID 为名仍可见
assert listing[live]["label"] == live # 本次登记未起名,同样以 ID 为名(一致行为)
# 既有 11 支与通用助手状态零改写;孤儿条目未被 CyberGril 覆盖或删除
assert set(BUILTIN) <= set(listing)
assert stack.generic_voice_writes == 0
assert stack.deletes == []
def test_session_refuses_when_worker_state_unwritable(instance):
"""通话开启时 worker 音色写不进:诚实 503;恢复后正常开启,选择不受牵连."""
client, stack, custom, worker_state, tmp_path = instance
registered = _register(client).json()
assert client.post("/api/voice/preview", json={"voice": registered["id"]}).status_code == 200
client.post("/api/voice/selection", json={"voice": registered["id"]})
blocked = tmp_path / "blocked-worker.json"
blocked.mkdir()
custom.config.worker_state_file = str(blocked)
response = client.post("/api/voice/session")
assert response.status_code == 503
assert "写不进" in response.json()["detail"]
custom.config.worker_state_file = str(worker_state)
assert client.post("/api/voice/session").status_code == 201
def test_worker_state_other_keys_and_corruption(instance):
"""可读的 worker 文件其他键保留;文件损坏(非 JSON)时重写只剩 voice —— 如实记录行为."""
client, stack, custom, worker_state, tmp_path = instance
registered = _register(client).json()
client.post("/api/voice/preview", json={"voice": registered["id"]})
worker_state.write_text(json.dumps(
{"voice": "default", "persona": "keep-me", "volume": 7}), encoding="utf-8")
client.post("/api/voice/selection", json={"voice": registered["id"]})
state = json.loads(worker_state.read_text())
assert state["voice"] == registered["id"]
assert state["persona"] == "keep-me" and state["volume"] == 7
worker_state.write_text("{not json", encoding="utf-8")
other = _register(client).json()
client.post("/api/voice/preview", json={"voice": other["id"]})
client.post("/api/voice/selection", json={"voice": other["id"]})
state = json.loads(worker_state.read_text())
# 损坏文件读不出 → 整体重写仅含 voice,旧键丢失(该文件由专属 worker 维护;
# 损坏属异常现场。记录事实,不构成合同违约)
assert state == {"voice": other["id"]}
def test_journal_unwritable_keeps_apply_gated(instance):
"""试听流水写不进:试听仍可成功(尽力而为),但应用凭据缺失 → 409 守门不放松."""
client, stack, custom, worker_state, tmp_path = instance
blocked = tmp_path / "blocked-usage.jsonl"
blocked.mkdir()
custom.journal_path = blocked
registered = _register(client).json()
assert client.post("/api/voice/preview", json={"voice": registered["id"]}).status_code == 200
refused = client.post("/api/voice/selection", json={"voice": registered["id"]})
assert refused.status_code == 409 # fail-closed:宁可再听一次,不放过未试听
def test_apply_rolls_back_when_selection_file_unwritable(instance):
"""本地选择状态写不进:整体回滚,worker 文件不动,错误如实 503."""
client, stack, custom, worker_state, tmp_path = instance
registered = _register(client).json()
client.post("/api/voice/preview", json={"voice": registered["id"]})
worker_state.write_text(json.dumps({"voice": "rain"}), encoding="utf-8")
blocked = tmp_path / "blocked-sel.json"
blocked.mkdir()
custom.selection_path = blocked
response = client.post("/api/voice/selection", json={"voice": registered["id"]})
assert response.status_code == 503
assert "写不进" in response.json()["detail"]
assert json.loads(worker_state.read_text())["voice"] == "rain"
assert custom.selection().voice is None