Files
CyberGril/docs/rework/hel879/timing-fix/summary.md
T
zonggongandmultica-agent fc905fe6bd HEL-879 timing closure: the three contract-approval findings fixed as implementation-timing only (v2 envelope fields/event kinds untouched, web/src untouched, no re-audit of passed binding/backup/budget work) - (1) open_session re-kicks the cleanup owner AFTER registration+persist: a real dispatch await yields the loop and the pre-dispatch kick's cleaner already exited with nothing to watch (manager repro: 20ms real dispatch await, TTL 25ms, 120ms of silence -> DELETE=0, expired registration still present, cleaner=None; fixed: exactly one DELETE, registration settled, no dangling coroutine - the bounded auto-expiry/shared-cap mechanism itself is unchanged), new regressions reproduce first on the old code with a genuinely-yielding dispatch stand-in (asyncio.sleep, not the instantly-returning MockTransport) plus an earlier-deadline wake negative; (2) speak_proactive_row takes the handle the real sync AgentSession.say returns (iscoroutine only unwraps await-style stand-ins - the real SpeechHandle itself defines __await__, so awaiting it would wait out the whole playout), publishes proactive_start IMMEDIATELY, registers the handle's own end/cancel, then waits out that one row (sequential rows kept); .12 real-SDK probe: start visible while handle_done=false, end only after, cancel-only on interrupt, v2 fields identical; (3) _after_playout gates assistant_final on the completed+legal-same-turn-receipt conditions BEFORE anything is sent - incomplete streams, missing/partial receipts and stale epochs close with turn_cancel only (reason incomplete/no-receipt), never final-then-unack; the complete+receipt path keeps BOTH final and ack; phi/interrupt/failure/unattributed fail-closed unchanged; real-SDK check extended to 14/14 ALL PASS on .12 (proactive timing, proactive interrupt, no-receipt no-final negatives all driving the actual worker module's registered callbacks), before/after evidence in docs/rework/hel879/timing-fix/ and sdk-check-hel879-timing.log, reusable probe generator scripts/gen_hel879_timing_probe.py; regression at this head: pytest 352/352 x3 consecutive runs, ruff(src/tests/tools) clean, verify_hel877_voice_chain 8/8, requirements checks 4/4 (origin/main and c121c42, each with/without handoff-HEL-879.json, handoff appended with this round's scope); zero real LLM/embed/ASR/TTS, zero downloads, .12 touched read-only via SSH stdin only, not deployed, nothing merged, PR#11 and hel-870-assembly untouched, real caps stay 0
Co-authored-by: multica-agent <github@multica.ai>
2026-10-27 05:31:05 +08:00

4.8 KiB
Raw Blame History

HEL-879 契约核准后 3 处实现时机收口(2026-10-27)

核对对象:hel-879-assembly @ 3228b1e0d7f822b7030f945e285eac74126771aa(总管《HEL-879-契约核准与接续.md》)。 本轮只修 3 处实现与已定契约 v2 不一致的问题:不改 v 信封字段、不改事件种类、不动 web/src、不重做已过绑定/备份/预算。

修前复现(全部先复现失败,与总管证据一致)

# 发现 修前证据
1 open_session 只在 await create_dispatch 前 kick;建房真实让出事件循环后 cleaner 空转退出,注册后无人再观察截止时间 delayed-dispatch-before.json:{"remote_delete_count_after_ttl": 0, "expired_but_registered": true, "cleanup_owner_present": false}(与总管 delayed-dispatch.json 逐字段一致)
2 speak_proactive_row 旧写法 handle = await session.say(...);真实 SDK say 为同步函数、await handle=等整段播完 → 播完才发 proactive_start sdk-probe-before.json:proactive_before_playout_finishes.events=[],播完后才 ["proactive_start","proactive_end"]
3 _after_playout 先按 stream.text 发 final/end 再查回执;注入 text 非空、completed=False、receipt=None 的确切归属流 → turn_start→assistant_final→turn_end,与 v2「无完整同轮回执只 cancel」相反 同上:no_receipt_incomplete_actual_worker_events=["turn_start","assistant_final","turn_end"](实际 worker bind_voice_session 回调路径,真实 AgentSession/SpeechHandle)

新增专项测试在未修复代码上先跑失败(repro-first): tests/test_voice_rework_hel879.py::test_open_session_with_real_dispatch_yield_re_kicks_cleanup、 ::test_second_registration_wakes_a_sleeping_cleaner_for_earlier_deadline → 修前 2 failed / 修后 2 passed。

修复(精确小修)

  1. src/cybergril/voice/session.py open_session:注册成功并 _persist_state() 之后重新 _kick_cleanup()——建房 await 期间旧 cleaner 可能已因「无可观察对象」退出,新截止时间需要新的所有者;自动截止、共享上限、重启补偿、有界放弃全部保留。测试用真正让出事件循环的建房替身(asyncio.sleep(0.02)),不再只用立即返回的 MockTransport 替身。
  2. src/cybergril/voice/worker.py speak_proactive_row:先取 handle(iscoroutine 只解包 await 型替身——真实 SpeechHandle 自身可 await,不能误等)、立即发布 proactive_start、注册该 handle 的 proactive_end/proactive_cancel,再按需等待这一条播完(逐条顺序念保留)。
  3. src/cybergril/voice/worker.py _after_playout:assistant_final 前置必要门——流 completed 且 should_ack(合法同轮 receipt、非旧 epoch)才 finish+confirm;未完整/无回执/旧 epoch 一律只 turn_cancel(reason=incomplete/no-receipt),绝不先发 final 再决定不确认;φ/打断/失败/无归属 fail-closed 原样保留,完整+合法回执路径的 final 与 ack 均保留。

修后验证

  • 本地:delayed-dispatch-after.json:{"remote_delete_count_after_ttl": 1, "expired_but_registered": false, "cleanup_owner_present": false}(cleaner 在全部结清后按设计自行退出,无悬空协程)。
  • .12 真 SDK(stdin 嵌入当前源码,零写入零网络,/opt/voice-agent/agent/venv livekit-agents 1.8.8)sdk-probe-after.json:
    • proactive_before_playout_finishes={"handle_done": false, "events": ["proactive_start"]}(播放中即可见 start);
    • 播完 ["proactive_start","proactive_end"],字段 v/kind/role/text/speech_id/message_id 与 v2 完全一致;
    • no_receipt_incomplete=["turn_start","turn_cancel"]、confirmed=[];
    • 完整+合法回执路径 ["turn_start","assistant_final","turn_end"] + ack 保留。
  • 真SDK回归并入常驻检查 scripts/gen_hel877_sdk_check.py(14/14 ALL PASS,../sdk-check-hel879-timing.log):新增「主动播报时机」「主动播报打断只有 cancel」「无回执不 final」三负例,均驱动实际 worker 模块注册的真实回调。
  • 产品套件:pytest 352/352(3 次连跑);ruff(src/tests/tools) 0 错;verify_hel877_voice_chain.py 8/8;需求检查 4/4(origin/main 与 c121c42 各带/不带 handoff-HEL-879.json)。字幕样例 subtitle-samples/ 与 v2 契约文档无需再生成(字段与生命周期未变)。

边界

零真实 LLM/嵌入/ASR/TTS/麦克风;.12 仅 SSH stdin 执行只读检查(零写入零网络,stderr 仅既有 jwt 弱密钥告警);零下载零安装;未部署、未合并;PR#11 与 hel-870-assembly 未动;权威账/冻结/共享服务/队员配置未碰;真实 cap 仍 0。R5 真机、完整 CG-011、R6/R7 继续承接,无需求变更(v1.11/60 项)。