Compare commits

..
Author SHA1 Message Date
施工员2号andmultica-agent 07dda2bfa2 HEL-904: backend rework — recoverable register-failure mapping, worker-state corruption docstring, call-window voice pin closes capture/worker race
- register(): local metadata write failure -> LocalRecordFailed -> 503 Chinese
  message with the cg- id (shared registration NOT rolled back, refresh list to
  recover, never re-register); register_asr journaled BEFORE local persist so
  failed registrations still count in the resource ledger
- write_worker_voice(): docstring now states the whole-file rewrite on
  unreadable/corrupt state (other keys preserved only when parseable)
- capture->worker race closed with a session-immutable-snapshot equivalent:
  _capture_voice holds the captured voice (no await gap); apply_selection
  defers its worker-state write while any call is live; open_session releases
  the hold on failure/cancel; VoiceSessionService.on_session_settled releases
  on explicit end, end-into-debt and TTL expiry; response voice == the voice
  the worker actually reads for that call, mid-call changes affect the next
  call only; overlapping-call residual documented honestly
- tests: hel902 fault-injection case 1 rewritten for the new contract
  (recovery + restart + 409 gate kept), 4 new deterministic Event-gated race
  tests (worker read delayed past dispatch, concurrent apply, failure/cancel
  cleanup, next-call voice), zero sleep-based assertions
- docs: contract §2.2/§2.5/§3 updated; r5 followup package tightened (head
  milestones d167ba9/b09a907/pending-review, register ASR vs call ASR split,
  call-ASR counting means + honest pending blocker, single-timebase frame
  silence measurement, D1 female_soft reference audio pinned by sha256,
  recycling checklist, shared-TTS snapshot/restore, git-revert is not
  recycling, caps count failures/retries/outbound-no-response)
- evidence: pytest 380/380 (376 baseline + 4), verify chain 12/12, ruff clean,
  requirement checks 4/4 (origin/main and b09a907, with/without handoff)

Co-authored-by: multica-agent <github@multica.ai>
2026-10-28 01:09:19 +08:00
9 changed files with 687 additions and 53 deletions
+28
View File
@@ -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,不宣称消除"
]
}
+160 -36
View File
@@ -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。
+58
View File
@@ -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 完整功能持续承接;
本卡通过≠自定义音色用户验收,更≠完整产品完成。
+22 -1
View File
@@ -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 顺序)
+80 -8
View File
@@ -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,
+46 -4
View File
@@ -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
+19
View File
@@ -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
+31 -4
View File
@@ -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):
+243
View File
@@ -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"