Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07dda2bfa2 |
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"baseline_version": "1.12",
|
||||
"read_commit": "b09a907747f9836c4aea04016757321d28b7cb6b",
|
||||
"task": "HEL-904:HEL-902 统一审核后的后台有限返修(HEL-899 原作者)。三项:①登记在8090成功而本地元数据落盘失败→503中文映射(编号可恢复、共享登记不回滚、不逼重复登记),ASR流水先记再落盘;②worker状态文件损坏整体重写的实况补入docstring/合同,正常可读文件仍保留其他键;③修通话捕获音色与worker实读竞态:以会话不可变快照(通话窗口持有)保证响应voice=worker本通话实读声音、通话中改选只影响下一通,覆盖失败/取消/挂断失败转欠账/TTL到期结算,确定性Event门控无sleep;④补测申请材料收紧(仅材料不执行)。零真实调用、零部署、零下载、不动web/与共享worker、不新增依赖",
|
||||
"requirement_ids": ["CG-006", "CG-009", "CG-010", "CG-013", "CG-014", "CG-015", "CG-016", "CG-018", "CG-019", "CG-020", "CG-021", "AM-001", "GOV-001", "GOV-002", "GOV-003", "GOV-004", "GOV-005", "GOV-006", "DEL-001"],
|
||||
"deliverable": "独立分支(自b09a907)后台返修:custom.py登记失败映射LocalRecordFailed+ASR流水先记、write_worker_voice损坏实况docstring、通话持有hold/release+apply暂缓写、router捕获登记/失败取消释放/503映射/settle挂钩、session.py on_session_settled结算(挂断成功/转欠账/TTL到期);tests/test_voice_review_hel902.py第1例按新契约改写(含重启恢复与409守门断言)、新增tests/test_voice_rework_hel904.py确定性时序4例;合同docs/voice-custom-contract-HEL899.md §2.2/§2.5/§3更新;docs/rework/hel902/r5-followup-package.md收紧(head里程碑表:d167ba9=整合提交、b09a907=审核head、本卡head待统一复审登记,登记ASR与连续通话ASR区分、连续通话ASR计数手段与待补阻塞如实声明、停声量测单一时间基准帧级录音、参考音频明确D1包female_soft段含sha256/长度、回收清单与不未经确认保留测试垃圾、共享TTS启停快照恢复、git回退不代替共享回收、上限含失败口径与硬闸/监看界线);docs/rework/hel904/REWORK.md返修记录",
|
||||
"acceptance": [
|
||||
"登记成功+本地元数据写失败:503中文文案含编号、明确共享登记不回滚、刷新列表可找回并试听/应用、不逼重复登记(tests/test_voice_review_hel902.py改写例:含重启按编号label=编号继续用、新音色未试听409守门不放松、11支内置与通用助手零改写)",
|
||||
"worker状态文件损坏实况:docstring/合同如实注明整体重写仅剩voice,不再宣称任何情况下保留其他键;正常可读文件其他键保留(既有用例继续通过)",
|
||||
"竞态收口:确定性Event门控测试把worker读时点延迟到dispatch返回之后、并发应用新声音后——本通话worker读到=响应voice=旧音色;挂断结算后下一通capture用新声音;挂断失败转欠账、TTL到期、dispatch失败、请求取消四条路径都释放持有、应用随即写透;无sleep凑测试(tests/test_voice_rework_hel904.py 4例)",
|
||||
"补测材料:仅材料不执行,候选上限只收紧不放宽,一切失败/取消/重试/出站无响应计入口径;LLM/嵌入=程序硬闸与其余工程监看界线写明;连续通话ASR无程序计数手段处如实标待补阻塞;真实cap=0保持"
|
||||
],
|
||||
"deferred": [
|
||||
"真实登记/可选转写/真实试听/应用与下一通验证、克隆相似度、打断停声帧级量测、真机麦克风路径:待主卡批准补测包后执行,未批前真实cap=0",
|
||||
"本卡后台修复与前端另卡(外接服务文案、会话页显示本次音色)需原总工一次统一复审;通过后登记最终已验证head,此前任何head不得当作执行依据",
|
||||
"连续通话ASR分钟级音频时长与连接重试的程序计数手段:待补阻塞,窗口前由总管在.12验证LiveKit/worker侧指标可执行性,不通过则窗口不得启动含连续通话步骤",
|
||||
"前端同锁文件npm安装复跑:单独待批项,本轮不执行",
|
||||
"自定义音色删除、完整备份/恢复(CG-009/R6)后续卡;R5整体验收与R1-R7完整功能持续承接;本卡通过≠自定义音色用户验收,更≠完整产品完成"
|
||||
],
|
||||
"requirement_changes": [],
|
||||
"verification": [
|
||||
"pytest tests -q:380通过(基线376+新增4,py3.11.16复用既有venv零安装),全量含HEL-899 18项与总工故障注入全数通过",
|
||||
"python -I scripts/verify_hel899_custom_voice.py:12/12(含通话中改选定住本通话、下一通生效)",
|
||||
"ruff check src tests scripts:通过",
|
||||
"需求检查4/4:--base-ref origin/main 与 --base-ref b09a907 各带/不带本handoff全部通过",
|
||||
"未验证(如实):真实worker读时点、真实dispatch/房间、打断停声与真机路径——全部留在获批补测窗口;重叠通话不同音色的残余竞态如实记录于合同§2.5与REWORK.md,不宣称消除"
|
||||
]
|
||||
}
|
||||
@@ -1,55 +1,179 @@
|
||||
# HEL-902 补测包:自定义声音真机窗口申请材料(交总管集中请批)
|
||||
|
||||
状态:**材料备齐,未获批前真实 cap=0**。所有上限为**新的一次性窗口授权**,不复用、不重开 HEL-879 已封账(其 14/40 请求、42717/500000 tokens、12/200 TTS 段保持封存;剩余数≠新许可)。工程参数由工程定,用户只批准真实用量与设备操作。
|
||||
状态:**材料备齐,未获批前真实 cap=0;本包只写材料,绝不执行**。所有上限为
|
||||
**新的一次性窗口授权**,不复用、不重开 HEL-879 已封账(其 14/40 请求、
|
||||
42717/500000 tokens、12/200 TTS 段保持封存,永不重开;剩余数≠新许可)。
|
||||
工程参数由工程定(只能收紧、不能放宽),用户只批准真实用量与设备操作。
|
||||
|
||||
## 1. 统一 head 与部署范围
|
||||
## 0. 统一 head 与复审状态(2026-10-28 HEL-904 后更新,旧报告保持历史可追溯)
|
||||
|
||||
- 候选整合分支 `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 行为;资料空间三个新文件留存无害。
|
||||
- `d167ba9` 是 **hel-902-integration 的整合合并提交**(历史事实,见
|
||||
`docs/rework/hel902/REVIEW.md`,其 376/376 等证据属于当时 head)。
|
||||
- HEL-902 统一审核通过时的分支 head 为 **`b09a907`**(README 勘误与补测包
|
||||
所在提交);此后该分支未再作为交付 head 使用。
|
||||
- **后台返修(HEL-904,本卡)**:独立分支 `agent/2/f337aae1f551`
|
||||
(自 `b09a907` 起),登记失败恢复映射、worker 状态损坏实况、capture 竞态
|
||||
收口三项修复与确定性时序测试;其 head 提交号见本卡交付评论,**该 head
|
||||
在统一复审通过前不得当作最终已验证 head**。
|
||||
- **前端返修(另卡,原作者)**:外接服务文案等;head 以该卡交付为准。
|
||||
- 后端本轮与前端另卡**还需原总工一次统一复审**;通过后由复审在下方登记
|
||||
「最终已验证 head」,再由主卡向白栖知申请批准本窗口。此前任何 head 都
|
||||
不是执行依据。
|
||||
|
||||
| 里程碑 | head | 状态 |
|
||||
| --- | --- | --- |
|
||||
| 整合合并(历史) | `d167ba9` | 已被后续提交覆盖,仅作历史 |
|
||||
| HEL-902 统一审核 | `b09a907` | 审核通过+一轮有限返修(已启动) |
|
||||
| 后台返修 HEL-904 | 见 HEL-904 交付评论 | **待统一复审** |
|
||||
| 前端返修(另卡) | 见该卡交付 | **待统一复审** |
|
||||
| 最终已验证 head | 待统一复审填写 | 复审通过后填写,在此之前不得开窗 |
|
||||
|
||||
## 1. 部署形态与回滚
|
||||
|
||||
- 沿用 HEL-877/879 runbook:隔离双入口 8360/8361 + 8363 WSS 桥 + 专属
|
||||
worker 8082 + 共享 TTS 9880(窗口内临时恢复,窗口后停回原状态);
|
||||
`config/voice.yaml` 按合同 §4 增补。
|
||||
- **不触碰** 8300–8350 演示/真实档案、8081、7880、旧资料与冻结账、4080;
|
||||
ComfyUI 保持暂停;不合 PR、不替换正式界面。
|
||||
- 8090 语音面板:窗口内**只读调用其查询 API**;获批登记步骤**经其上传
|
||||
API 写入本窗口 `cg-` 条目**(面板仍是共享注册表唯一写者)。两句话分开
|
||||
说清,不用「8090 只读」一笔带过获准后的写入。
|
||||
- 回滚:不部署即无痕;已部署时停止窗口进程+移除 voice.yaml 增补即回
|
||||
HEL-877 行为;资料空间三个新文件留存无害。**共享注册表/测试数据的回收
|
||||
只能按 §3 清单经 8090 自有删除入口逐条完成,不得拿 git 回退或重启代替**。
|
||||
|
||||
## 2. 测试目标与逐步动作(全部可观察、可中止)
|
||||
|
||||
1. **真实登记**:浏览器(桌面 Chrome/Edge,LAN 机)上传一段 5–20 秒 WAV 参考音频 → `cg-` 登记 → 列表出现。
|
||||
2. **可选 ASR**(≤3 次):留空参考文字、显式开转写开关 → FunASR 转写回显;另做一次「不开开关且无文字→422 拒绝」负例。
|
||||
3. **真实试听**(≤10 次合成):现场合成 WAV 浏览器播放;负例:TTS 停机→503。
|
||||
4. **应用与下一通验证**(≤3 次通话):试听后应用 → 开通话核对 worker 实际音色=会话响应 `voice` 字段;通话中改选→本通话不换声、下一通生效;从未选择→不代写 worker 文件。
|
||||
1. **真实登记**:浏览器(桌面 Chrome/Edge,LAN 机)上传一段 5–20 秒 WAV
|
||||
参考音频(来源见 §3)→ `cg-` 登记 → 列表出现。
|
||||
2. **可选登记转写 ASR**(≤3 次):留空参考文字、显式开转写开关 → FunASR
|
||||
转写回显;另做一次「不开开关且无文字→422 拒绝」负例。
|
||||
3. **真实试听**(≤10 次合成):现场合成 WAV 浏览器播放;负例:TTS 停机
|
||||
→503。另做一次「未试听直接应用→409」负例(守门保持)。
|
||||
4. **应用与下一通验证**(≤3 次实际通话):试听后应用 → 开通话核对
|
||||
worker 实际音色=会话响应 `voice` 字段;通话中改选→本通话不换声、
|
||||
下一通生效(通话窗口持有语义,合同 §2.5);从未选择→不代写 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,两主题×电脑/手机 + 演示入口零外发断言)。不获批则此项留待后续。
|
||||
- **打断停声(约 1 秒)**:量测点必须从**用户实际开口/有效打断输入的
|
||||
时点**起,到**浏览器实际播放的旧音停止**为止。
|
||||
- 主测法(最小、单一时间基准):LAN 机上一次连续的「屏幕+标签页
|
||||
输出音频」采集(Chrome 标签页音频采集/OBS,全程单录音文件)。
|
||||
该采集只录浏览器播放出的声音(**不混入本地麦克风**),打断操作
|
||||
的时点在画面上可见(说话动作/发送文字的动作),T0=输入起始帧、
|
||||
T1=旧音最后一帧可闻帧,停声=T1−T0;同一段录音里再标出新回复
|
||||
起播点与提示音位置(提示音按固定音色/波形辨认并排除,不并入
|
||||
停声),帧级分析即毫秒级时基,不需要跨机对钟。
|
||||
- worker 日志 `turn_cancel`→最后 TTS 段发送时间差**只是内部子段
|
||||
证据**(附日志行),不能单独证明用户听到了约 1 秒停声;人工秒表
|
||||
仅作辅助复核。
|
||||
- **文字打断语音**与**真实设备麦克风打断**两个方向都做:文字方向=
|
||||
她语音输出中发送文字→声即停;麦克风方向必须用 LAN 机真实麦克风
|
||||
(非 fake device,不把假设备当真机)。
|
||||
- 真实浏览器/设备麦克风路径:LAN 机真实麦克风完成第 4 项;如用户
|
||||
愿意,iPhone Safari 经 8363 WSS + 证书信任步骤试一连(可选,失败
|
||||
不影响其余结论)。
|
||||
6. **窗口内快检**(可选,**单独待批项**:一次性 npm 同锁文件安装授权,
|
||||
本轮与后端返修都不执行):本机 `npm ci` → 复跑 vitest/tsc/eslint/
|
||||
build + `hel900_mock.mjs`+`hel900_shots.mjs`。不获批则留待后续。
|
||||
|
||||
## 3. 参考音频来源与共享注册表新增/回收
|
||||
|
||||
- 来源:优先用总管自备的一段真实人声 WAV(用户已授权「音色你自己决定」适用窗口测试音色);或复用 D1 试听包中已只读取回的既有参考录音之一作克隆源(不新增采集、不新录用户声音、不申请摄像头)。
|
||||
- 共享 `voices.json` 新增:仅 `cg-` 命名空间新条目(服务端生成,结构上不可能碰既有 11 支;面板仍是唯一写者)。**回收**:窗口结束按登记清单逐条经 8090 面板自有删除入口删除并截图/列表复核;保留一条经用户后续确认去留。元数据/流水在实例资料空间,随实例清理。
|
||||
- 全局默认音色、通用助手 `voice_state.json`/`persona_state.json` 不触碰(测试断言已含)。
|
||||
- **参考音频(已明确到段)**:首选 D1 试听包中 `female_soft` 的 zero-shot
|
||||
参考录音——来源服务器路径 `/opt/voice-agent/cosyvoice/voice_samples/
|
||||
female_soft.wav`,本地清单 `docs/rework/hel879/d1-audition/manifest.json`
|
||||
记录 sha256 `4a65d17099239711601cb17e28b7aa7b6149acd153b92fd0366c23902bd4f687`、
|
||||
341192 字节、10.7 秒、16k/mono(中文女声、时长在 5–20s 建议区间)。
|
||||
备选 `xiaoyun.wav`(`6ad3ca7a…dc87e`、297004 字节、4.6 秒、32k/mono)。
|
||||
窗口执行前只读重新取回并**先核对 sha256 与清单一致**再用;不新增采集、
|
||||
不新录用户声音、不申请摄像头。**授权测试音色由总管决定**;无需为此
|
||||
再问用户算法或端口。
|
||||
- 共享 `voices.json` 新增:仅 `cg-` 命名空间新条目(服务端生成,结构上
|
||||
不可能碰既有 11 支;面板仍是唯一写者);不覆盖原 11 支与全局默认。
|
||||
- **回收(窗口结束逐项执行)**:按本窗口登记清单(含孤儿条目)逐条经
|
||||
8090 面板自有删除入口删除,删除前后各留一次列表快照比对;**回收失败
|
||||
的条目按「编号/名称/登记时间/失败原因」留精确清单交接**,不删不属于
|
||||
本窗口的孤儿,也**不未经用户确认保留任何一条测试垃圾**——默认全部
|
||||
回收,确需保留的逐条写明理由交总管向用户确认。元数据/流水在实例资料
|
||||
空间,随实例清理。
|
||||
- 全局默认音色、通用助手 `voice_state.json`/`persona_state.json` 不触碰
|
||||
(测试断言已含)。
|
||||
|
||||
## 4. 时窗与逐类上限(一次性,超出即停)
|
||||
## 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 回收。
|
||||
| 类别 | 候选上限 | 计数口径(含失败/取消/重试/出站无响应) | 闸门性质 |
|
||||
| --- | --- | --- | --- |
|
||||
| 时窗 | ≤2 小时(建议 1 小时) | 首个真实请求起算 | 工程守则(负责人盯表) |
|
||||
| 实际语音通话 | ≤3 次 | 通话真实发生数 | 工程守则 |
|
||||
| 开房(含空会话) | ≤6 次 | `POST /api/voice/session` 每次调用(成功/失败/取消都算) | 程序可数(API 日志/会话账本) |
|
||||
| LLM 请求 | ≤20 | 统一账本 acquire | **程序硬闸**(`think/budget.py`) |
|
||||
| LLM tokens | ≤60000 | 同上 | **程序硬闸** |
|
||||
| 嵌入 | 0 | acquire 拒绝 | **程序硬闸** |
|
||||
| 登记转写 ASR | ≤3 | `voice-usage.jsonl` `register_asr` 行(含本地元数据落盘失败者) | 工程监看(程序计数、非硬闸) |
|
||||
| 试听合成 | ≤10 | `preview_tts` 行(含 TTS 出站无成功响应者) | 工程监看(同上) |
|
||||
| 通话 TTS 段 | ≤40 | worker 侧流水/日志计数 | 工程监看(见下,需窗口前验证命令) |
|
||||
| 音色登记(8090 upload) | ≤3 | 上传出站次数(含断连后已生效者) | 工程监看(面板上传账) |
|
||||
| **连续通话 ASR 音频时长** | ≤45 分钟(3 通 × ≤15 分钟实操上限) | 见下:**待补阻塞** | 工程监看(无程序闸) |
|
||||
| **连续通话 ASR 连接重试** | ≤6 次/通 | 见下:**待补阻塞** | 工程监看(无程序闸) |
|
||||
| 新增付费支出 | 0 | 全程零付费 API | 工程守则 |
|
||||
|
||||
## 5. 旧服务与资料保护清单(窗口内不变式)
|
||||
**硬闸与监看的界线(如实)**:只有 LLM 请求/tokens 与嵌入是代码级硬闸
|
||||
(统一账本 acquire,超限直接拒绝);其余上限是**工程监看或程序计数**,
|
||||
靠实时看数+负责人停机,**不拿事后流水冒充硬闸**。
|
||||
|
||||
8300/8310 演示与真实档案进程不动;8090 只读调用+授权后的 cg- 写入;7880/8081 不碰;既有数据库、身份、聊天、记忆不重置;测试写入只落隔离实例数据空间;每日自动备份任务(如有)不中断;不动队员模型/运行时/认证;不申请摄像头。
|
||||
**连续通话 ASR(worker 内 FunASR 连续识别,区别于登记转写 ASR)的计数
|
||||
手段与停机**:
|
||||
|
||||
- 墙钟代理(可程序数,命令可执行):实例旁会话账本 `<db>-voice-sessions.json`
|
||||
记录每通 `created_at`(开房)与转入清理时的 `first_debt_at`(≈挂断)。
|
||||
已结通话墙钟合计:
|
||||
`jq '[.debts[] | (.first_debt_at - .session.created_at)] | add // 0' <db>-voice-sessions.json`;
|
||||
在途通话看 `.sessions[]` 的 `created_at`(到点人工掐表)。通话墙钟总时长
|
||||
≥麦克风推流时长的上界,再加 §2 每通 ≤15 分钟操作掐表即 ≤45 分钟总上限
|
||||
的执行手段;窗口负责人:总管。到线即停:停开新会话并挂断在途会话
|
||||
(`DELETE /api/voice/session/{id}`)。
|
||||
- **待补阻塞(如实声明,不假称齐备)**:逐通精确音频时长与 ASR 连接
|
||||
重试次数没有本仓库可执行的程序计数——worker 属共享 .12 栈、不改其代
|
||||
码。候选手段是窗口前由总管在 .12 上验证可执行性:LiveKit 服务端房间
|
||||
指标/日志(如 `lk room list`/服务日志中该房间的 track 与重连事件)或
|
||||
worker 日志采样。**窗口开始前若验证不通过,该项保持阻塞,窗口不得
|
||||
启动含连续通话的步骤**;不得以墙钟代理冒充分钟级音频上限。
|
||||
|
||||
**停机方法**:任一上限达到即停(LLM/嵌入自动拒;其余实时盯
|
||||
`voice-usage.jsonl`、统一账本与会话账本);窗口结束停 8360/8361/8082/
|
||||
8363,9880 停回原状态(见 §5 快照),`ss -ltn` 复核端口、房间经 API
|
||||
挂断或 TTL 清理,账本与流水快照存档到 `docs/rework/hel902/`,孤儿 `cg-`
|
||||
条目按 §3 回收。
|
||||
|
||||
## 5. 旧服务与资料保护清单(窗口内不变式,逐项核对)
|
||||
|
||||
- 8300/8310 演示与真实档案进程不动;7880/8081 不碰;既有数据库、身份、
|
||||
聊天、记忆不重置;测试写入只落隔离实例数据空间;每日自动备份任务
|
||||
(如有)不中断;不动队员模型/运行时/认证;不申请摄像头。
|
||||
- **共享 TTS 9880 启停保护**:窗口开始前快照其当前状态(是否在运行、
|
||||
进程/容器 id、启动参数:`systemctl status`/`docker ps` + `ps` 输出
|
||||
存档);窗口内临时恢复;窗口结束**按快照恢复原状**(原本停机则停回
|
||||
停机),恢复后再次 `ss -ltn | grep 9880` + 进程比对留证。
|
||||
- **端口/房间/临时声音/日志逐项核对表**(窗口结束逐项打勾并存证):
|
||||
端口 8360/8361/8363/8082 全停;9880 恢复原状;LiveKit 房间清空
|
||||
(`DELETE /api/voice/session/*` 或 TTL);`voice_samples/` 无本窗口
|
||||
残留 wav(对照 `cg-` 登记清单);窗口专属日志与账本快照归档
|
||||
`docs/rework/hel902/`;共享 `voices.json` 与 11 支内置前后 diff 仅
|
||||
本窗口 `cg-` 且已按 §3 回收。
|
||||
- **不能用 git 回退代替共享注册表/测试数据回收**(git 只管仓库文件,
|
||||
不管 8090 面板与服务器状态)。
|
||||
|
||||
## 6. 通过判据
|
||||
|
||||
- 登记→(可选转写)→试听→应用→下一通话用新声音,全链真实完成且日志对得上;通话中改选不中途换声。
|
||||
- 打断停声时延有毫秒级日志证据;文字打断语音方向有观察记录。
|
||||
- 共享注册表前后 diff 仅 `cg-` 新增且回收完成;11 支内置与全局默认零变化(前后快照比对)。
|
||||
- 全部上限内完成;结束后无残留窗口进程/端口/房间。
|
||||
- 登记→(可选转写)→试听→应用→下一通话用新声音,全链真实完成且日志
|
||||
对得上;通话中改选不中途换声(会话响应 `voice`=worker 实际音色)。
|
||||
- 打断停声时延有**同一时间基准的帧级录音证据**(输入时点→旧音停止,
|
||||
排除提示音/新回复/麦克风混音);文字打断语音方向有观察记录;真实
|
||||
麦克风路径用的是真机不是假设备。
|
||||
- 共享注册表前后 diff 仅 `cg-` 新增且回收完成(或留精确失败交接清单);
|
||||
11 支内置与全局默认零变化(前后快照比对)。
|
||||
- 全部上限内完成(含失败与重试口径);结束后无残留窗口进程/端口/房间;
|
||||
付费支出为 0。
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# HEL-904 后台有限返修记录(登记失败恢复 / worker 状态实况 / capture 竞态)
|
||||
|
||||
作者:施工员2号(HEL-899 原作者)。2026-10-28。基准 v1.12 @ `b09a907`。
|
||||
零真实 LLM/嵌入/ASR/TTS 调用、零共享音色写入、零部署、零依赖下载安装、
|
||||
未动 web/、未改共享 worker、未新增依赖。不重做架构、不改网页。
|
||||
|
||||
对应 `docs/rework/hel902/REVIEW.md` §5 的第 2、3、4、6 项(后台部分)。
|
||||
|
||||
## 1. 改动清单
|
||||
|
||||
| 文件 | 改动 |
|
||||
| --- | --- |
|
||||
| `src/cybergril/voice/custom.py` | ① `register()`:本地元数据落盘失败从裸 OSError 改为 `LocalRecordFailed`(共享登记不回滚、编号写进中文提示、刷新可找回继续试听/应用、不逼重复登记);`register_asr` 流水改在本地落盘**之前**记(转写真实发生,资源口径必计)。② `write_worker_voice()` docstring 补损坏实况:文件读不出时整体重写仅剩 voice,不再宣称任何情况下保留其他键;正常可读文件行为不变。③ 新增通话持有 `hold/release_call_voice`/`call_voice_held`;`apply_selection()` 在有活跃通话时只记选择、暂缓写 worker 文件(整通话窗口按「worker 尚未读」对待),无活跃通话行为与原先一致(含回滚)。 |
|
||||
| `src/cybergril/voice/router.py` | `_capture_voice()` 捕获后登记通话持有(无 await 间隙);`open_session` 失败/取消即释放;`register_voice` 映射 `LocalRecordFailed`→503 中文文案;挂载 `on_session_settled`→释放持有;头部契约注释更新。 |
|
||||
| `src/cybergril/voice/session.py` | 新增可选 `on_session_settled` 回调;会话离开活跃表(显式挂断成功、挂断失败转欠账、TTL 到期 `_prune`)即结算。回调异常只影响释放时机(安全方向),不拖垮会话流程。 |
|
||||
| `tests/test_voice_review_hel902.py` | 第 1 例按新契约改写:503 中文映射+编号在文案里+不误称回滚+进程内可试听可应用+重启按编号(label=编号)继续用+新音色未试听仍 409+11 支/通用助手零改写。 |
|
||||
| `tests/test_voice_rework_hel904.py`(新增 4 例) | 确定性时序用例(asyncio.Event 门控,无 sleep 判定):worker 读时点延迟到 dispatch 返回之后+并发应用→本通话保持旧音色(响应=实读)、挂断结算、下一通用新音色;挂断失败转欠账与 TTL 到期两条结算路径;dispatch 失败即释放(应用立即写透);开房请求在途取消即释放。 |
|
||||
| `docs/voice-custom-contract-HEL899.md` | §2.2 补共享登记成功/本地写失败与断连孤儿行为;§2.5 补通话窗口持有语义(含重叠通话如实保留的残余);§3 worker 文件损坏实况。 |
|
||||
| `docs/rework/hel902/r5-followup-package.md` | 补测材料收紧(仅材料):head 里程碑表(d167ba9=整合提交、b09a907=审核 head、返修 head 待统一复审登记)、登记/连续通话 ASR 区分、连续通话 ASR 计数手段与待补阻塞如实声明、停声量测改为单一时间基准帧级录音法、参考音频明确到 D1 包具体段(含 sha256/长度)、回收清单与「不未经确认保留测试垃圾」、共享 TTS 启停快照恢复与逐项核对表、git 回退不代替共享回收、上限表补含失败口径与硬闸/监看界线。 |
|
||||
|
||||
## 2. 竞态收口的事实依据与设计
|
||||
|
||||
- 事实入口(不猜):专属 worker 是**另一进程**,在**它自己的会话入口**读
|
||||
一次 `voice_state_cybergril.json`(HEL-877 既有行为,合同 §2.5;本仓库
|
||||
不含 worker 代码,不改共享 worker)。API 只知道 dispatch 返回时刻,
|
||||
**dispatch 返回 ≠ worker 已读**。
|
||||
- 设计=等价「会话不可变快照」:capture 时(写文件后、任何 await 前)登记
|
||||
按音色计数的通话持有;活跃通话期间 `apply` 只更新选择(本地选择仍原子
|
||||
落盘),worker 文件定在本通话捕获值上;挂断/到期/失败/取消结算持有。
|
||||
暂缓的写不需要补写:下一次通话 capture 总会按当前选择重写文件(从未
|
||||
选择仍不代写,维持 D1 边界)。
|
||||
- 保证:**会话响应 `voice` = worker 本通话实际读到并使用的声音**;通话中
|
||||
改选只影响下一通,绝不半句换人。
|
||||
- 如实保留的残余(不宣称消除):两通**重叠**(单用户产品退化场景)且选了
|
||||
不同声音时,较新通话的 capture 会重写文件;较早通话的 worker 若迟迟
|
||||
未读,可能读到较新捕获值。每个 worker 仍整通话单一音色。确定性用例
|
||||
覆盖的是正常单通话流。
|
||||
|
||||
## 3. 自测(全部离线,零真实调用;py3.11.16)
|
||||
|
||||
- `pytest tests -q`:**380 通过**(基线 376+新增 4;改写的第 1 例保持
|
||||
原名)。含既有 18 项 HEL-899 与总工 6 项故障注入全数通过。
|
||||
- `python -I scripts/verify_hel899_custom_voice.py`:**12/12**(含通话中
|
||||
改选定住本通话、下一通生效)。
|
||||
- `ruff check src tests scripts`:通过。
|
||||
- 需求检查 4 组(origin/main 与 b09a907,各带/不带本卡 handoff):通过
|
||||
(证据见交付评论)。
|
||||
|
||||
## 4. 未验证 / 承接(如实)
|
||||
|
||||
- 真实 worker 读时点、真实 dispatch/房间行为、打断停声与真机麦克风路径:
|
||||
属获批补测窗口(材料已收紧,见 `docs/rework/hel902/r5-followup-package.md`)。
|
||||
- 本卡修复(后台)与前端另卡仍需原总工**一次统一复审**;通过后由复审
|
||||
登记最终已验证 head,再由主卡向白栖知申请批准新窗口。复审前不得把本
|
||||
head 当作最终已验证 head。
|
||||
- 前端「会话页显示本次通话声音」(审核建议第 5 项)属 web/,归前端另卡。
|
||||
- CG-009/R6 删除与完整备份恢复、R5 整体验收、R1–R7 完整功能持续承接;
|
||||
本卡通过≠自定义音色用户验收,更≠完整产品完成。
|
||||
@@ -107,6 +107,14 @@ Content-Type: audio/wav (或 application/octet-stream)
|
||||
"音色登记服务这会儿不在,稍后再试。";登记成功但列表未出现 `422`
|
||||
"登记结果没有出现在音色列表里,请重试。"(不留下半条可选项)。
|
||||
|
||||
**共享登记成功、本地元数据写不进**(HEL-904 补):`503` 中文文案如实说明
|
||||
编号与恢复路径——声音**已在共享注册表登记成功(不回滚)**,本地记录写不
|
||||
进时列表里暂时只显示编号;**刷新列表即可按编号找回并直接试听/应用,不
|
||||
需要重新登记**(重复登记只会另立新编号、留下无元数据孤儿条目)。响应
|
||||
断连重试同样会另立新 `cg-` 编号并在共享注册表留下以编号显示的孤儿条目
|
||||
(可正常使用,回收经 8090 面板自有删除入口,CyberGril 不代管)——实际
|
||||
行为如实声明,不自动删除。
|
||||
|
||||
**建议录音格式**(前端 MediaRecorder 录完后转):WAV(PCM),16k 或 24k 采样,
|
||||
单声道,16 位,5–20 秒,安静环境念 1–2 句完整的话。API 端校验是
|
||||
"合法 WAV + ≥2 秒";时长上限由 15MB 大小线自然约束。
|
||||
@@ -147,6 +155,16 @@ POST /api/voice/selection {"voice": "cg-9f2c1a77"}
|
||||
- 生效时机:worker 在会话入口读一次音色 → **通话中改选最迟下一通话生效**,
|
||||
绝不半句换人;`POST /api/voice/session` 的响应新增 `"voice"` 字段 =
|
||||
本次通话捕获的有效音色(选择已失效则诚实回落 `"default"`)。
|
||||
- **通话窗口持有**(HEL-904 收口 capture 与 worker 实读之间的竞态):
|
||||
worker 是另一进程、读时点对 API 不可知(dispatch 返回 ≠ 已读),因此
|
||||
开房 capture 把捕获值登记为通话持有,整个通话窗口(capture→挂断/到期)
|
||||
都按「worker 尚未读」对待:期间 `POST /api/voice/selection` 只更新选择、
|
||||
**暂缓写 worker 状态文件**,文件定在本通话捕获值上——会话响应的
|
||||
`voice` 与 worker 本通话实际读到并使用的声音一致;挂断/到期/开房失败
|
||||
或取消即结算持有,之后的应用立即写透。重叠通话属单用户产品的退化
|
||||
场景:较新通话的 capture 会重写文件,较早通话若其 worker 迟迟未读,
|
||||
可能读到较新捕获值(每个 worker 仍整通话单一音色,无半句换人)——
|
||||
如实记录,不宣称完全消除。
|
||||
|
||||
## 3. 状态与资源(可核算、可恢复)
|
||||
|
||||
@@ -162,7 +180,10 @@ CyberGril 资料空间(实例数据库旁,与语音清理账同模式,重
|
||||
每次记 `register_asr` 流水;试听每次记 `preview_tts`。LLM 调用零新增。
|
||||
- 备份清单:`GET /api/voice/voices` 的 custom 项即完整清单(参考音频路径 +
|
||||
参考文本 + 映射);恢复 = 按清单重新登记(面板无导出接口,属 R6 备份卡范围)。
|
||||
- worker 文件 `voice_state_cybergril.json`:原子写且**保留文件里其他键**;
|
||||
- worker 文件 `voice_state_cybergril.json`:原子写且在文件**可解析(JSON
|
||||
对象)时保留其他键**;文件读不出(损坏/非 JSON/非对象)时会**整体重写**
|
||||
为仅含 `voice` 的新状态,旧键此时无法保留(HEL-902 审核 §4 核出的实况,
|
||||
不得宣称任何情况下都保留其他键;正常可读文件其他键原样保留)。
|
||||
用户从未经 API 选择时,通话开启不代写该文件(部署/D1 域不替用户定)。
|
||||
|
||||
## 4. 部署说明(本卡未执行;总工按 HEL-877 runbook §4 顺序)
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
* 应用 = 写 CyberGril 自己的音色状态(worker 的
|
||||
``voice_state_cybergril.json``),通话开启时由 router 捕获并落同一文件,
|
||||
worker 在会话入口读一次——通话中改选最迟下一通话生效,绝不半句换人。
|
||||
HEL-904 起,capture 与 worker 实读之间的竞态以「通话窗口持有」收口:
|
||||
活跃通话期间选择状态的 worker 写入暂缓(见 ``hold_call_voice``)。
|
||||
|
||||
命名空间:新增音色一律 ``cg-`` + 8 位十六进制(服务端生成,不收用户起名),
|
||||
不可能撞上/覆盖共享注册表里的既有 11 支音色,也不会互相覆盖。显示名
|
||||
@@ -72,6 +74,14 @@ class RegistrationFailed(Exception):
|
||||
"""面板拒绝或返回异常——登记未成立,不留下半条可选项。"""
|
||||
|
||||
|
||||
class LocalRecordFailed(Exception):
|
||||
"""共享登记已成功、本地元数据落盘失败(HEL-904).
|
||||
|
||||
面板侧登记**不回滚**(也回滚不了):声音以编号留在共享注册表里,
|
||||
刷新列表即可找回并继续试听/应用,绝不要求重复登记。
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class CustomVoiceConfig:
|
||||
panel_url: str = "https://127.0.0.1:8090"
|
||||
@@ -151,6 +161,7 @@ class CustomVoiceService:
|
||||
self._lock = asyncio.Lock()
|
||||
self._selection = self._load_selection()
|
||||
self._custom: dict[str, dict] = self._load_custom()
|
||||
self._call_voice_holds: dict[str, int] = {}
|
||||
self._prune_journal()
|
||||
|
||||
# -- 持久化(CyberGril 资料空间;重启读回) ------------------------------
|
||||
@@ -258,7 +269,13 @@ class CustomVoiceService:
|
||||
"""登记参考音频:服务端复验 → cg- 专属 ID → 面板 zero-shot 注册.
|
||||
|
||||
失败不留下半条可选项:面板拒绝/异常时不写任何 CyberGril 元数据,
|
||||
面板自己对失败的 wav 有清理(ASR 失败即删,见其源码)。
|
||||
面板自己对失败的 wav 有清理(ASR 失败即删,见其源码)。共享登记
|
||||
成功而本地元数据落盘失败时抛 :class:`LocalRecordFailed`(503 中文
|
||||
提示):共享登记不回滚、按编号可恢复,绝不逼重复登记;进程内
|
||||
记录保留可用,后续任一次成功登记会把整份元数据(含这一条)一并
|
||||
写回(自愈)。响应断连后重试会另立新 cg- 编号,共享注册表留下
|
||||
无元数据孤儿条目(列表以编号显示、可正常使用)——实际行为如此
|
||||
如实声明,不自动删除、不代管 8090 自有的删除入口.
|
||||
"""
|
||||
if not data:
|
||||
raise RegistrationFailed("请选择一段参考音频。")
|
||||
@@ -313,10 +330,24 @@ class CustomVoiceService:
|
||||
"prompt_wav": entry.get("prompt_wav"),
|
||||
"registered_at": int(self.clock() * 1000),
|
||||
}
|
||||
self._custom[voice_id] = record
|
||||
self._persist_custom()
|
||||
if record["prompt_text_source"] == "asr":
|
||||
self._journal("register_asr", voice_id) # 显式获准的 FunASR 转写,可核算
|
||||
# 流水在本地元数据落盘之前记:面板侧转写真实发生了,即使
|
||||
# 本地记录写不进,资源口径也必须计入(尽力而为,不因后续
|
||||
# 失败抹掉已发生的用量)。
|
||||
self._custom[voice_id] = record
|
||||
try:
|
||||
self._persist_custom()
|
||||
except OSError as exc:
|
||||
# 共享登记已成立(面板不回滚);只坏本地元数据。进程内记录
|
||||
# 仍在(本进程可试听/应用),重启后元数据丢、共享注册表仍
|
||||
# 列出该声音(label=ID)——按编号找回即可,不逼重复登记。
|
||||
raise LocalRecordFailed(
|
||||
f"{voice_id} 已经在音色登记服务登记成功(共享登记不会因为"
|
||||
"本地记录失败而回滚);但本地记录这会儿写不进,列表里它会"
|
||||
f"暂时只显示编号。刷新声音列表就能按编号 {voice_id} 找到它,"
|
||||
"直接试听并应用就行,不需要重新登记。"
|
||||
) from exc
|
||||
return dict(record)
|
||||
|
||||
async def preview_pcm(self, *, voice: str, text: str | None) -> bytes:
|
||||
@@ -363,7 +394,13 @@ class CustomVoiceService:
|
||||
return ""
|
||||
|
||||
def write_worker_voice(self, voice: str) -> None:
|
||||
"""原子写 worker 音色状态(保留文件里其他键);失败抛 OSError,不半写."""
|
||||
"""原子写 worker 音色状态;文件可解析(JSON 对象)时保留其他键.
|
||||
|
||||
实况(HEL-902 审核 §4 核出,合同 §3 同步注明):文件读不出
|
||||
(损坏/非 JSON/非对象)时会**整体重写**为仅含 ``voice`` 的新状态,
|
||||
旧键此时无法保留——不得宣称任何情况下都保留其他键。正常可读
|
||||
文件的其他键原样保留;任何失败抛 OSError,不半写.
|
||||
"""
|
||||
path = Path(self.config.worker_state_file)
|
||||
try:
|
||||
state = json.loads(path.read_text(encoding="utf-8"))
|
||||
@@ -374,12 +411,44 @@ class CustomVoiceService:
|
||||
state["voice"] = voice
|
||||
_atomic_write_json(path, state)
|
||||
|
||||
# -- 通话音色定住(HEL-904:修 capture 与 worker 实读之间的竞态) --------
|
||||
# worker(另一进程)在自己的会话入口读一次状态文件,读时点对 API 进程
|
||||
# 不可知——dispatch 返回 ≠ worker 已读(HEL-902 审核 §4 残余竞态)。
|
||||
# 等价的「会话不可变快照」:把整个通话窗口(capture→挂断/到期)都当作
|
||||
# 「worker 可能还没读」。通话期间选择状态的 worker 写入全部暂缓,
|
||||
# 状态文件定在本通话捕获值上;worker 无论何时来读,读到的都是本
|
||||
# 通话响应里报告的那个声音。暂缓的写入不需要显式补写:下一次通话
|
||||
# 的 capture 总会按当前选择重写(从未选择则不代写,维持不代写边界)。
|
||||
def hold_call_voice(self, voice: str) -> None:
|
||||
"""登记一个活跃通话捕获的音色(开房 capture 时调用,先于任何 await)."""
|
||||
if voice:
|
||||
self._call_voice_holds[voice] = self._call_voice_holds.get(voice, 0) + 1
|
||||
|
||||
def release_call_voice(self, voice: str) -> None:
|
||||
"""通话结算(挂断/到期/开房失败或取消)时释放对应持有."""
|
||||
count = self._call_voice_holds.get(voice, 0) - 1
|
||||
if count > 0:
|
||||
self._call_voice_holds[voice] = count
|
||||
else:
|
||||
self._call_voice_holds.pop(voice, None)
|
||||
|
||||
def call_voice_held(self) -> bool:
|
||||
"""是否存在活跃通话持有(True 时应用只记选择、不写 worker 文件)."""
|
||||
return bool(self._call_voice_holds)
|
||||
|
||||
async def apply_selection(self, *, voice: str) -> VoiceSelection:
|
||||
"""把她后续通话的发声换成指定音色(最迟下一通话生效).
|
||||
|
||||
自定义(cg-)音色必须先成功试听过(「应用前有可听试听」);内置
|
||||
音色是既有 D1 候选,不强制。写 worker 状态文件与本地选择状态都
|
||||
原子落盘;任一失败整体回滚,绝不留下两边不一致的半状态。
|
||||
音色是既有 D1 候选,不强制。本地选择状态原子落盘;worker 状态
|
||||
文件**在没有活跃通话时**同步原子写入,任一失败整体回滚,绝不留
|
||||
下两边不一致的半状态。有活跃通话时(HEL-904)worker 文件定在
|
||||
该通话捕获值上——本次只更新选择,worker 文件不动(worker 入口
|
||||
读时点不可知,整通话窗口视为未读);新音色由下一通话的 capture
|
||||
重写生效,仍是「最迟下一通话生效」,绝不半句换人。重叠通话属
|
||||
单用户产品的退化场景:较新通话的 capture 会重写文件,较早通话
|
||||
若其 worker 迟迟未读,可能读到较新捕获值(每个 worker 仍整通话
|
||||
单一音色,无半句换人)——如实记录,不在本接口内伪装消除。
|
||||
"""
|
||||
if not voice or not await self._voice_exists(voice):
|
||||
raise KeyError(voice)
|
||||
@@ -389,18 +458,21 @@ class CustomVoiceService:
|
||||
previous = VoiceSelection(self._selection.voice, self._selection.label,
|
||||
self._selection.updated_at)
|
||||
record = self._custom.get(voice)
|
||||
wrote_worker = False
|
||||
try:
|
||||
self._selection = VoiceSelection(
|
||||
voice=voice,
|
||||
label=(record["label"] if record else voice),
|
||||
updated_at=int(self.clock() * 1000))
|
||||
self._persist_selection()
|
||||
self.write_worker_voice(voice)
|
||||
if not self._call_voice_holds: # 无活跃通话才写透 worker 文件
|
||||
wrote_worker = True
|
||||
self.write_worker_voice(voice)
|
||||
except OSError as exc:
|
||||
self._selection = previous
|
||||
with contextlib.suppress(OSError):
|
||||
self._persist_selection() # 回滚本地状态
|
||||
if previous.voice:
|
||||
if wrote_worker and previous.voice:
|
||||
self.write_worker_voice(previous.voice)
|
||||
raise StateWriteFailed(f"voice state write: {type(exc).__name__}") from exc
|
||||
return VoiceSelection(self._selection.voice, self._selection.label,
|
||||
|
||||
@@ -9,7 +9,15 @@ POST /api/voice/session open a room, dispatch the dedicated worker,
|
||||
else the worker file's value, else default)
|
||||
and pinned for THIS call — changes applied
|
||||
mid-call take effect at the NEXT call at
|
||||
the latest, never mid-sentence.
|
||||
the latest, never mid-sentence. HEL-904:
|
||||
the capture also HOLDS the worker-state
|
||||
value for the whole call window (worker
|
||||
reads that file once at its own session
|
||||
entry, at an unknowable time — a dispatch
|
||||
response is NOT proof it has read); any
|
||||
apply during a live call defers its worker
|
||||
write, so response voice == the voice the
|
||||
worker actually reads for this call.
|
||||
DELETE /api/voice/session/{id} end: delete the LiveKit room, which
|
||||
disconnects browser and worker, ends the
|
||||
agent job and releases the microphone.
|
||||
@@ -39,7 +47,11 @@ POST /api/voice/preview synthesize a FRESH sample with a voice and
|
||||
GET /api/voice/selection read her persisted voice selection.
|
||||
POST /api/voice/selection apply a voice to her subsequent calls
|
||||
(custom voices require a successful preview
|
||||
first; builtin D1 candidates do not).
|
||||
first; builtin D1 candidates do not). While
|
||||
a call is live the worker-state write is
|
||||
deferred (HEL-904): the selection is
|
||||
recorded, the file stays pinned to the
|
||||
active call's captured voice.
|
||||
|
||||
capabilities.voice in /api/health stays false: R5 is not real-accepted yet and
|
||||
this endpoint never claims end-to-end voice availability.
|
||||
@@ -47,11 +59,14 @@ this endpoint never claims end-to-end voice availability.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import Response
|
||||
|
||||
from .custom import (
|
||||
CustomVoiceService,
|
||||
LocalRecordFailed,
|
||||
RegistrationFailed,
|
||||
RegistryUnavailable,
|
||||
StateWriteFailed,
|
||||
@@ -72,7 +87,14 @@ REFUSAL_CLEANUP_ABANDONED = (
|
||||
|
||||
def _capture_voice(service: VoiceSessionService, custom: CustomVoiceService | None) -> str:
|
||||
"""通话开启时的有效音色(HEL-899):选择有效→选择;否则 worker 文件现值;
|
||||
再无效→ default。用户从未经 API 选择时不改写 worker 文件(D1 域不代定)。"""
|
||||
再无效→ default。用户从未经 API 选择时不改写 worker 文件(D1 域不代定)。
|
||||
|
||||
HEL-904:捕获值同时登记「通话持有」(先于本函数返回、无 await 间隙)。
|
||||
worker 在另一进程的会话入口读一次状态文件、读时点不可知(dispatch
|
||||
返回 ≠ 已读),因此整个通话窗口都按「尚未读」对待:期间应用的
|
||||
worker 写入暂缓,状态文件定在本通话捕获值上——响应里的 voice 与
|
||||
worker 实际读到并使用的声音一致;通话中改选只影响下一通。
|
||||
"""
|
||||
voices = service.last_health_voices()
|
||||
|
||||
def valid(candidate: str) -> bool:
|
||||
@@ -86,15 +108,23 @@ def _capture_voice(service: VoiceSessionService, custom: CustomVoiceService | No
|
||||
custom.write_worker_voice(captured) # 本通话用捕获值;worker 入口读一次
|
||||
except OSError:
|
||||
raise HTTPException(503, REFUSAL_VOICE_STATE_WRITE) from None
|
||||
custom.hold_call_voice(captured)
|
||||
return captured
|
||||
current = custom.read_worker_voice()
|
||||
return current if valid(current) else "default"
|
||||
captured = current if valid(current) else "default"
|
||||
custom.hold_call_voice(captured) # 不代写,也要定住现值供本通话
|
||||
return captured
|
||||
return ""
|
||||
|
||||
|
||||
def build_voice_router(service: VoiceSessionService | None,
|
||||
custom: CustomVoiceService | None = None) -> APIRouter:
|
||||
router = APIRouter(prefix="/api/voice", tags=["voice"])
|
||||
if service is not None and custom is not None:
|
||||
# HEL-904:会话结算(挂断/到期/转欠账)→ 释放该通话的音色持有,
|
||||
# 之后的应用立即写透 worker 文件。capture 时已持有的窗口由此收口。
|
||||
service.on_session_settled = (
|
||||
lambda session: custom.release_call_voice(session.voice))
|
||||
|
||||
@router.get("/status")
|
||||
async def status() -> dict:
|
||||
@@ -121,7 +151,15 @@ def build_voice_router(service: VoiceSessionService | None,
|
||||
captured = _capture_voice(service, custom)
|
||||
try:
|
||||
session = await service.open_session(epoch=state.epoch, voice=captured)
|
||||
except asyncio.CancelledError:
|
||||
# 请求被取消(客户端断开等):会话未成立,释放通话持有,
|
||||
# 不让一次取消把后续应用无限暂缓。
|
||||
if custom is not None:
|
||||
custom.release_call_voice(captured)
|
||||
raise
|
||||
except Exception:
|
||||
if custom is not None:
|
||||
custom.release_call_voice(captured) # 开房失败:持有随请求结算
|
||||
raise HTTPException(503, REFUSAL_LIVEKIT_DOWN) from None
|
||||
return {
|
||||
"url": service.config.livekit_url,
|
||||
@@ -210,6 +248,10 @@ def build_voice_router(service: VoiceSessionService | None,
|
||||
prompt_text=prompt_text, allow_asr=allow_asr)
|
||||
except RegistrationFailed as exc:
|
||||
raise HTTPException(422, str(exc)) from None
|
||||
except LocalRecordFailed as exc:
|
||||
# 共享登记已成功、本地元数据写不进:如实告知编号与恢复路径,
|
||||
# 不裸 500,也不误导用户以为共享登记回滚了而去重复登记.
|
||||
raise HTTPException(503, str(exc)) from None
|
||||
except RegistryUnavailable:
|
||||
raise HTTPException(503, REFUSAL_PANEL_DOWN) from None
|
||||
return record
|
||||
|
||||
@@ -222,6 +222,12 @@ class VoiceSessionService:
|
||||
cleanup_max_attempts: int = CLEANUP_MAX_ATTEMPTS
|
||||
cleanup_backoff_seconds: float = CLEANUP_BACKOFF_SECONDS
|
||||
cleanup_grace_seconds: float = CLEANUP_GRACE_SECONDS
|
||||
# HEL-904: 可选回调 ``Callable[[VoiceSession], None]``。一个会话离开
|
||||
# 活跃表(显式挂断、TTL 到期、或转入清理欠账)即「结算」:该通话
|
||||
# worker 的入口读窗口关闭。router 用它释放自定义音色的通话持有
|
||||
# (capture 与 worker 实读之间的竞态收口)。回调异常不结算失败拖垮
|
||||
# 会话流程(持有多留一会儿只会让应用晚一拍写透,安全方向)。
|
||||
on_session_settled: object = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self._sessions: dict[str, VoiceSession] = {}
|
||||
@@ -319,6 +325,16 @@ class VoiceSessionService:
|
||||
return self._tts_health.voices
|
||||
|
||||
# -- lifecycle ----------------------------------------------------------
|
||||
def _settle(self, session: VoiceSession) -> None:
|
||||
"""A session left the active registry (ended, expired, or moved into
|
||||
the cleanup ledger): its worker's entry-read window is closed. The
|
||||
optional HEL-904 hook releases the custom-voice hold for that call."""
|
||||
hook = self.on_session_settled
|
||||
if hook is None:
|
||||
return
|
||||
# 见字段注释:持有释放失败只会让应用写透晚一拍,不破坏会话流程
|
||||
with contextlib.suppress(Exception):
|
||||
hook(session)
|
||||
def _prune(self) -> None:
|
||||
"""Expired registrations move into the cleanup ledger — never silently
|
||||
dropped: their room stays owned until its bounded delete lands."""
|
||||
@@ -326,6 +342,7 @@ class VoiceSessionService:
|
||||
expired = [k for k, v in self._sessions.items() if v.expires_at < now]
|
||||
for session_id in expired:
|
||||
session = self._sessions.pop(session_id)
|
||||
self._settle(session) # TTL 到期:入口读窗口关闭(HEL-904)
|
||||
if session_id not in self._debts:
|
||||
self._debts[session_id] = _CleanupDebt(session=session, first_debt_at=now)
|
||||
if expired:
|
||||
@@ -502,6 +519,7 @@ class VoiceSessionService:
|
||||
# From now on the room lives in the ledger alone: _sessions no
|
||||
# longer bypasses the shared cap on a later end call.
|
||||
self._sessions.pop(session_id, None)
|
||||
self._settle(session) # 已挂断(删房在欠账里补):入口读窗口关闭
|
||||
if session_id not in self._debts:
|
||||
self._debts[session_id] = debt
|
||||
debt.attempts += 1
|
||||
@@ -513,6 +531,7 @@ class VoiceSessionService:
|
||||
self._kick_cleanup()
|
||||
raise
|
||||
self._sessions.pop(session_id, None)
|
||||
self._settle(session) # 删房落定:本通话结算(HEL-904 释放音色持有)
|
||||
self._debts.pop(session_id, None)
|
||||
self._persist_state()
|
||||
return session
|
||||
|
||||
@@ -3,14 +3,17 @@
|
||||
针对总管列出的统一审核重点,用可控假服务注入故障,零真实调用:
|
||||
|
||||
1. 8090 已登记、CyberGril 本地元数据落盘失败 —— 是否留下无法恢复或重复注册的半状态。
|
||||
(HEL-904 返修:503 中文映射 + 编号可恢复 + 重启断言 + 409 守门保持。)
|
||||
2. 登记上传成功后面板响应断连(结果已生效、响应丢失)+ 用户重试 —— 共享注册表里出现什么。
|
||||
3. 通话开启时 worker 音色状态写不进 —— 诚实拒绝且不开房。
|
||||
4. worker 状态文件可读时其他键保留;文件损坏(读不出 JSON)时其他键是否被丢弃。
|
||||
(HEL-904:该实况已补入 docstring 与合同,行为不变、如实记录。)
|
||||
5. 试听流水写不进 —— 「应用前有可听试听」凭据缺失时应用是否守门(fail-closed)。
|
||||
6. 选择状态落盘失败 —— 整体回滚,worker 文件不被写。
|
||||
|
||||
时间与真实性边界:worker 实际在会话入口读音色文件、与真实 worker 的时序一致性
|
||||
属真机窗口验证范围(见 docs/rework/hel902/),本文件只锁 API 侧可离线证明的事实。
|
||||
属真机窗口验证范围(见 docs/rework/hel902/);本文件锁 API 侧可离线证明的事实,
|
||||
capture→worker 实读竞态的确定性时序用例见 tests/test_voice_rework_hel904.py。
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -56,21 +59,28 @@ def _swap_panel(custom, stack, handler):
|
||||
|
||||
|
||||
def test_panel_ok_local_metadata_write_fails(instance):
|
||||
"""8090 登记成功、本地元数据写不进:500(未映射合同错误);状态仍可恢复、不逼重复注册."""
|
||||
"""8090 登记成功、本地元数据写不进(HEL-904 已修):不裸 500,中文提示
|
||||
说清编号、共享登记未回滚、刷新可恢复;不逼重复注册;重启后按编号可用;
|
||||
试听凭据 409 守门不放松."""
|
||||
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
|
||||
assert response.status_code == 503 # OSError 映射为可恢复的 503 中文文案
|
||||
detail = response.json()["detail"]
|
||||
voice_id = stack.uploads[0]["name"]
|
||||
assert len(stack.uploads) == 1 # 用户没被逼重复登记
|
||||
assert voice_id in detail # 编号直接给到用户手里
|
||||
assert "不会" in detail and "回滚" in detail # 不误称共享登记也回滚了
|
||||
assert "刷新" in detail and "不需要重新登记" in detail # 恢复路径说清楚
|
||||
# 面板已登记;进程内记录仍在:本进程里看得到、可试听可应用,不是死条目
|
||||
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
|
||||
assert client.get("/api/voice/selection").json()["voice"] == voice_id
|
||||
# 「重启」后元数据确实丢失(无 label/时间),但共享注册表仍在,声音仍可列出使用
|
||||
revived = CustomVoiceService(
|
||||
config=custom.config, http=custom.http,
|
||||
@@ -78,6 +88,23 @@ def test_panel_ok_local_metadata_write_fails(instance):
|
||||
journal_path=tmp_path / "usage.jsonl")
|
||||
assert voice_id in set(stack.ids)
|
||||
assert voice_id not in revived.custom_voices() # 元数据丢了,声音没丢
|
||||
assert not revived.call_voice_held()
|
||||
# 重启恢复后按编号继续用:列表 label=编号;新音色未试听仍 409 守门
|
||||
app2, custom2, worker2 = build_app(tmp_path, stack)
|
||||
from fastapi.testclient import TestClient
|
||||
with TestClient(app2) as client2:
|
||||
client2.post("/api/companion", json=CREATE)
|
||||
listing2_raw = client2.get("/api/voice/voices").json()
|
||||
by_id = {v["id"]: v for v in listing2_raw["voices"]}
|
||||
assert by_id[voice_id]["label"] == voice_id # 无元数据:以编号为名
|
||||
assert client2.post("/api/voice/preview", json={"voice": voice_id}).status_code == 200
|
||||
fresh = _register(client2).json()
|
||||
assert client2.post("/api/voice/selection", json={"voice": fresh["id"]}).status_code == 409
|
||||
assert client2.post("/api/voice/preview", json={"voice": fresh["id"]}).status_code == 200
|
||||
assert client2.post("/api/voice/selection",
|
||||
json={"voice": fresh["id"]}).status_code == 200
|
||||
assert set(BUILTIN) <= set(by_id)
|
||||
assert stack.generic_voice_writes == 0 and stack.deletes == []
|
||||
|
||||
|
||||
def test_upload_response_lost_then_retry_leaves_orphan(instance):
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
"""HEL-904 后台返修:capture 音色与 worker 实读竞态的确定性时序用例.
|
||||
|
||||
HEL-902 审核第 6 项的收口验证。事实前提(先核真实入口,不猜):
|
||||
|
||||
* 专属 worker 是**另一进程**,在自己的会话入口读一次
|
||||
``voice_state_cybergril.json``(HEL-877 既有行为,合同 §2.5);
|
||||
* API 进程只知道 dispatch 何时返回,**dispatch 返回 ≠ worker 已读**——
|
||||
worker 的读时点对 API 不可知;
|
||||
* 因此修复采用等价的「会话不可变快照」:capture 时登记通话持有,
|
||||
整个通话窗口(capture→挂断/到期)都按「worker 尚未读」对待,
|
||||
期间应用的 worker 写入暂缓,状态文件定在本通话捕获值上。
|
||||
|
||||
时序全部用 asyncio.Event 门控(无 sleep 凑数);「worker 读文件」由
|
||||
测试在 dispatch 返回之后亲自读文件扮演——这正是竞态窗口里最坏的读者。
|
||||
零真实 LLM/嵌入/ASR/TTS 调用、零网络(全假服务)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tests"))
|
||||
|
||||
from test_r2 import R2LLM # noqa: E402
|
||||
from test_voice_custom import CREATE, FakeVoiceStack, make_wav # noqa: E402
|
||||
|
||||
from cybergril.api.app import create_app # noqa: E402
|
||||
from cybergril.life.runtime import LifeConfig # noqa: E402
|
||||
from cybergril.think.router import build_router # noqa: E402
|
||||
from cybergril.think.scenes import Tier # noqa: E402
|
||||
from cybergril.voice.custom import CustomVoiceConfig, CustomVoiceService # noqa: E402
|
||||
from cybergril.voice.session import VoiceConfig, VoiceSessionService # noqa: E402
|
||||
|
||||
|
||||
class GatedLiveKitTransport(httpx.AsyncHTTPTransport):
|
||||
"""可门控的假 LiveKit:dispatch 进站即亮灯,返回被 gate 扣住.
|
||||
|
||||
这让测试能把「开房请求在途」精确固定成一个时间点:capture 已写文件、
|
||||
dispatch 尚未返回,此刻并发发起应用正是审核指出的竞态窗口。
|
||||
"""
|
||||
|
||||
def __init__(self, stack: FakeVoiceStack) -> None:
|
||||
super().__init__()
|
||||
self.stack = stack
|
||||
self.dispatch_in_flight = asyncio.Event()
|
||||
self.gate = asyncio.Event()
|
||||
self.fail_dispatch = False
|
||||
self.fail_delete = False
|
||||
|
||||
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
||||
if request.url.host == "tts.test": # TTS 健康/合成走既有桩
|
||||
return self.stack._handle(request)
|
||||
path = request.url.path
|
||||
if path == "/twirp/livekit.AgentDispatchService/CreateDispatch":
|
||||
self.dispatch_in_flight.set()
|
||||
await self.gate.wait() # 扣住 dispatch 响应,直到测试放行
|
||||
if self.fail_dispatch:
|
||||
raise httpx.ConnectError("dispatch wire cut")
|
||||
return httpx.Response(200, json={"id": "AD_1", "room": "r"})
|
||||
if path == "/twirp/livekit.RoomService/DeleteRoom":
|
||||
if self.fail_delete:
|
||||
raise httpx.ConnectError("delete wire cut")
|
||||
return httpx.Response(200, content=b"")
|
||||
return httpx.Response(404)
|
||||
|
||||
|
||||
def build_gated_app(tmp: Path, stack: FakeVoiceStack, transport: GatedLiveKitTransport):
|
||||
voice = VoiceSessionService(
|
||||
config=VoiceConfig(
|
||||
livekit_url="ws://voice.test:7880", livekit_internal="http://livekit.test",
|
||||
api_key="devkey", api_secret="test-secret-not-real",
|
||||
tts_health_url="http://tts.test/health"),
|
||||
http=httpx.AsyncClient(transport=transport))
|
||||
worker_state = tmp / "voice_state_cybergril.json"
|
||||
custom = CustomVoiceService(
|
||||
config=CustomVoiceConfig(
|
||||
panel_url="https://panel.test", tts_url="http://tts.test",
|
||||
registry_file=str(stack.registry_file),
|
||||
worker_state_file=str(worker_state)),
|
||||
http=httpx.AsyncClient(transport=stack.transport, verify=False),
|
||||
selection_path=tmp / "sel.json", registry_path=tmp / "custom.json",
|
||||
journal_path=tmp / "usage.jsonl")
|
||||
provider = R2LLM()
|
||||
router = build_router(providers={t: provider for t in (Tier.HIGH, Tier.STANDARD, Tier.LOW)})
|
||||
app = create_app(db_path=":memory:", router=router,
|
||||
life_config=LifeConfig(enabled=True, poll_seconds=3600),
|
||||
semantic_mode="stub", voice_service=voice,
|
||||
custom_voice_service=custom)
|
||||
return app, custom, worker_state
|
||||
|
||||
|
||||
def _worker_reads(worker_state: Path) -> str:
|
||||
"""扮演 worker 的会话入口读:读时点由测试决定(这里是 dispatch 返回之后)."""
|
||||
return str(json.loads(worker_state.read_text(encoding="utf-8")).get("voice", ""))
|
||||
|
||||
|
||||
async def _register_and_apply_old(client: httpx.AsyncClient) -> str:
|
||||
registered = await client.post(
|
||||
"/api/voice/voices", params={"prompt_text": "参考文字"},
|
||||
content=make_wav(3), headers={"Content-Type": "audio/wav"})
|
||||
assert registered.status_code == 201
|
||||
voice_id = registered.json()["id"]
|
||||
assert (await client.post("/api/voice/preview", json={"voice": voice_id})).status_code == 200
|
||||
applied = await client.post("/api/voice/selection", json={"voice": voice_id})
|
||||
assert applied.status_code == 200
|
||||
return voice_id
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gated(tmp_path):
|
||||
stack = FakeVoiceStack(tmp_path)
|
||||
transport = GatedLiveKitTransport(stack)
|
||||
app, custom, worker_state = build_gated_app(tmp_path, stack, transport)
|
||||
return app, stack, transport, custom, worker_state
|
||||
|
||||
|
||||
async def test_worker_read_after_dispatch_sees_pinned_voice(gated):
|
||||
"""核心时序:worker 读时点被延迟到 dispatch 返回之后、且发生在并发应用
|
||||
完成之后——本通话保持旧音色(响应=实读),下一通话使用新音色."""
|
||||
app, stack, transport, custom, worker_state = gated
|
||||
async with app.router.lifespan_context(app), httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://t") as client:
|
||||
await client.post("/api/companion", json=CREATE)
|
||||
old_voice = await _register_and_apply_old(client)
|
||||
|
||||
# 开房:capture 已写文件,dispatch 在途(gate 扣住响应)
|
||||
open_task = asyncio.create_task(client.post("/api/voice/session"))
|
||||
await asyncio.wait_for(transport.dispatch_in_flight.wait(), timeout=5)
|
||||
assert _worker_reads(worker_state) == old_voice # capture 已落盘
|
||||
|
||||
# 竞态窗口内并发应用新音色(dispatch 尚未返回,worker 必然还没读)
|
||||
apply_task = asyncio.create_task(
|
||||
client.post("/api/voice/selection", json={"voice": "rain"}))
|
||||
applied = await asyncio.wait_for(apply_task, timeout=5)
|
||||
assert applied.status_code == 200 # 应用成功:选择已记
|
||||
assert custom.selection().voice == "rain"
|
||||
# 关键断言(defer 生效):worker 文件仍定在本通话捕获值上
|
||||
assert _worker_reads(worker_state) == old_voice
|
||||
|
||||
transport.gate.set() # 放行 dispatch
|
||||
opened = (await asyncio.wait_for(open_task, timeout=5)).json()
|
||||
assert opened["voice"] == old_voice # 响应报告本通话捕获值
|
||||
|
||||
# worker 现在才读文件(dispatch 返回之后、通话仍进行中)
|
||||
assert _worker_reads(worker_state) == old_voice # 读到的=响应报告的
|
||||
|
||||
# 挂断结算 → 持有释放;暂缓的写入由下一通 capture 重写生效
|
||||
ended = await client.delete(f"/api/voice/session/{opened['session_id']}")
|
||||
assert ended.status_code == 200
|
||||
assert not custom.call_voice_held()
|
||||
assert _worker_reads(worker_state) == old_voice # 结算不补写,下一通重写
|
||||
transport.dispatch_in_flight.clear()
|
||||
second = (await client.post("/api/voice/session")).json()
|
||||
assert second["voice"] == "rain"
|
||||
assert _worker_reads(worker_state) == "rain" # 下一通:新音色生效
|
||||
await client.delete(f"/api/voice/session/{second['session_id']}")
|
||||
|
||||
|
||||
async def test_end_delete_failure_and_ttl_expiry_settle_hold(gated):
|
||||
"""挂断失败转清理欠账、TTL 到期(_prune)两条路径都结算持有."""
|
||||
app, stack, transport, custom, worker_state = gated
|
||||
async with app.router.lifespan_context(app), httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://t") as client:
|
||||
await client.post("/api/companion", json=CREATE)
|
||||
old_voice = await _register_and_apply_old(client)
|
||||
|
||||
transport.gate.set()
|
||||
opened = (await client.post("/api/voice/session")).json()
|
||||
assert opened["voice"] == old_voice
|
||||
|
||||
applied = await client.post("/api/voice/selection", json={"voice": "rain"})
|
||||
assert applied.status_code == 200
|
||||
assert _worker_reads(worker_state) == old_voice # 通话活跃:暂缓
|
||||
|
||||
# 显式挂断失败(删房线断)→ 503、会话转欠账、持有照样结算
|
||||
transport.fail_delete = True
|
||||
ended = await client.delete(f"/api/voice/session/{opened['session_id']}")
|
||||
assert ended.status_code == 503
|
||||
assert not custom.call_voice_held() # 转欠账即结算:持有释放
|
||||
# 暂缓的写入不靠结算补写(下一次 capture 会重写)——但结算后
|
||||
# 再次应用立即写透,这是面向用户的恢复路径
|
||||
assert _worker_reads(worker_state) == old_voice
|
||||
reapplied = await client.post("/api/voice/selection", json={"voice": "rain"})
|
||||
assert reapplied.status_code == 200
|
||||
assert _worker_reads(worker_state) == "rain"
|
||||
|
||||
# TTL 到期路径:再开一通、拨表到期,_prune 也要释放持有
|
||||
transport.fail_delete = False
|
||||
transport.dispatch_in_flight.clear()
|
||||
third = (await client.post("/api/voice/session")).json()
|
||||
assert third["voice"] == "rain"
|
||||
assert custom.call_voice_held()
|
||||
service = app.state.voice_service
|
||||
session_obj = service.get(third["session_id"])
|
||||
session_obj.expires_at = service.clock() - 1 # 拨到过去:立即到期
|
||||
service._prune()
|
||||
assert not custom.call_voice_held()
|
||||
|
||||
|
||||
async def test_dispatch_failure_releases_hold(gated):
|
||||
"""失败清理:dispatch 失败 → 503 且持有立即释放,应用马上写透."""
|
||||
app, stack, transport, custom, worker_state = gated
|
||||
async with app.router.lifespan_context(app), httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://t") as client:
|
||||
await client.post("/api/companion", json=CREATE)
|
||||
await _register_and_apply_old(client)
|
||||
|
||||
transport.fail_dispatch = True
|
||||
transport.gate.set()
|
||||
refused = await client.post("/api/voice/session")
|
||||
assert refused.status_code == 503
|
||||
assert not custom.call_voice_held() # 失败即结算,无泄漏
|
||||
|
||||
applied = await client.post("/api/voice/selection", json={"voice": "rain"})
|
||||
assert applied.status_code == 200
|
||||
assert _worker_reads(worker_state) == "rain" # 无活跃通话:立即写透
|
||||
|
||||
|
||||
async def test_cancelled_open_releases_hold(gated):
|
||||
"""取消清理:开房请求在途被取消(客户端断开)→ 持有释放,不无限暂缓."""
|
||||
app, stack, transport, custom, worker_state = gated
|
||||
async with app.router.lifespan_context(app), httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://t") as client:
|
||||
await client.post("/api/companion", json=CREATE)
|
||||
await _register_and_apply_old(client)
|
||||
|
||||
open_task = asyncio.create_task(client.post("/api/voice/session"))
|
||||
await asyncio.wait_for(transport.dispatch_in_flight.wait(), timeout=5)
|
||||
open_task.cancel() # dispatch 在途时客户端取消
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await open_task # 取消在解除 await 前已完成整个栈展开:持有已释放
|
||||
assert not custom.call_voice_held() # 取消路径也结算
|
||||
|
||||
applied = await client.post("/api/voice/selection", json={"voice": "rain"})
|
||||
assert applied.status_code == 200
|
||||
assert _worker_reads(worker_state) == "rain"
|
||||
Reference in New Issue
Block a user