- turnId 由编排器生成、loop Run 复用(硬前提:否则 turn_begin 与流帧各铸一个,客户端两级分桶立即失配) - StateSink 增带 turnId 的变体(未接线时退化为现签名,不丢状态帧) - 上行 chat.parallel(WS chat 帧 + REST 三入口同形;省略 = 逐字不变) - 单会话额度默认 2 / 硬上限 3 / <2 关闭;额度满或未声明 ⇒ 落回既有排队语义 - I1′:checkpoint 归属、历史配对、忙态计数三处从会话级单例升为路级 - session_idle 收紧为「所有在途路收尾且 pending 空」(仅并行时生效;单路逐字不变) - 预留接口(形状定死、无实现无接线):tool/legfile.go 的 FileClaimer / ErrFileBusy / WithLegID - 顺手修一个真 bug:并行路 ctx 派生自提交请求作用域,REST 同步入口一写响应就把它连坐杀死(改 context.WithoutCancel 派生) - 23 个新用例(含 -race -count=3);变异 6 条(turnId 复用 / idle 时点 / 并行升级 / checkpoint 键 / 编排器交 id / pending_ack reason) - 契约变更点 10 条待监工升 v0.65(本人未改 Docs/**)
119 lines
4.9 KiB
Go
119 lines
4.9 KiB
Go
package gateway
|
||
|
||
import (
|
||
"context"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||
)
|
||
|
||
// 本文件归属协同项 C1 的「语音回合收尾」:把一次语音回合的结果交回语音层,
|
||
// 由它合成音频并**定向**回发起这条语音的连接。
|
||
//
|
||
// 语音回合现在与文本回合走同一条路(进编排器 → 落库 → 流式 → 跨端同步),
|
||
// 因此音频回复不能还在提交那一刻同步等待 —— 回合是异步完成的(可能排队、
|
||
// 可能被中断)。编排器唯一知道「这个回合真正产出了什么」的地方是回合结算处
|
||
// (completeTurn,与 S3.5 回写文档结论同一收尾点),所以收尾回调在那里触发。
|
||
//
|
||
// 与 session_reject.go 同一纪律:编排器对传输层一无所知,「怎么把帧送回那根
|
||
// 连接」由 WS 边界在 ctx 上给出。
|
||
|
||
// VoiceReplyFunc 是语音回合的收尾回调:回合结算后,编排器把该回合的结果交给
|
||
// 它(res 可能为 nil —— 被取消或出错的回合没有结果)。
|
||
type VoiceReplyFunc func(res *loop.Result)
|
||
|
||
// voiceReplyKey 是携带语音收尾回调的 context 键。它从 WS 帧边界
|
||
// (voice.go 的 runVoiceTurn)引入编排器,与 deviceID / 幂等键同一种机制。
|
||
type voiceReplyKey struct{}
|
||
|
||
// withVoiceReplySink 返回携带语音收尾回调的 context。nil 回调是空操作:文本
|
||
// 回合(以及未接线传输层的提交)在 ctx 上不留任何痕迹。
|
||
func withVoiceReplySink(ctx context.Context, sink VoiceReplyFunc) context.Context {
|
||
if sink == nil {
|
||
return ctx
|
||
}
|
||
return context.WithValue(ctx, voiceReplyKey{}, sink)
|
||
}
|
||
|
||
// voiceReplySinkFrom 返回 ctx 携带的语音收尾回调,未设置时为 nil。
|
||
func voiceReplySinkFrom(ctx context.Context) VoiceReplyFunc {
|
||
sink, _ := ctx.Value(voiceReplyKey{}).(VoiceReplyFunc)
|
||
return sink
|
||
}
|
||
|
||
// voiceTurn 配对一条**路**与它的语音收尾回调(按世代号配对,与 turnDocuments
|
||
// 同理):被中断 / 已失效的路在结算时不该替别人回音频。
|
||
//
|
||
// sinks 是切片,因为一个回合可能同时有**多条**语音请求在等音频:忙时入 pending
|
||
// 的语音消息可能被转向注入(design/15 §2.4,见 session_steer.go 的 consumePending)
|
||
// 而由同一个回合作答 —— 它的音频必须由这个回合结算时一并送回,否则那条请求会
|
||
// 静默地收不到声音(音频回复不能丢)。
|
||
type voiceTurn struct {
|
||
gen uint64
|
||
sinks []VoiceReplyFunc
|
||
}
|
||
|
||
// voiceSinksOf 把单个回调收成切片(nil 回调 → nil 切片)。
|
||
func voiceSinksOf(sink VoiceReplyFunc) []VoiceReplyFunc {
|
||
if sink == nil {
|
||
return nil
|
||
}
|
||
return []VoiceReplyFunc{sink}
|
||
}
|
||
|
||
// rememberVoiceTurn 记下**本路**的语音收尾回调。**每条新路都必须调用它一次**
|
||
// (纯文本回合记 nil):否则一个陈旧的语音回调会被沿用到后面的文本回合上,
|
||
// 让一次文本问答无端合成一段音频。
|
||
//
|
||
// 它按路配对(design/15 §2.5.9):并行时两路各自持有自己的回调,一路结算
|
||
// 绝不会替另一路回音频。路已失效(被打断清空)时什么都不做。
|
||
func (o *Orchestrator) rememberVoiceTurn(sessionID string, gen uint64, sink VoiceReplyFunc) {
|
||
o.mu.Lock()
|
||
defer o.mu.Unlock()
|
||
if leg := o.legLocked(o.states[sessionID], gen); leg != nil {
|
||
leg.voice = voiceTurn{gen: gen, sinks: voiceSinksOf(sink)}
|
||
}
|
||
}
|
||
|
||
// addVoiceSinkLocked 把一条**被转向注入**的语音请求的收尾回调追加到该路(gen
|
||
// 所指的那一条)上。必须在 o.mu 下调用;该路已失效时什么都不做。
|
||
func (o *Orchestrator) addVoiceSinkLocked(st *sessionState, gen uint64, sink VoiceReplyFunc) {
|
||
if sink == nil {
|
||
return
|
||
}
|
||
leg := o.legLocked(st, gen)
|
||
if leg == nil {
|
||
return
|
||
}
|
||
leg.voice.sinks = append(leg.voice.sinks, sink)
|
||
}
|
||
|
||
// takeVoiceTurn 取走**本路**的全部语音收尾回调并清空记录。第二个返回值为 false
|
||
// 表示这条路已失效(被中断清空 / 被取代):迟到的结算不该回音频。
|
||
func (o *Orchestrator) takeVoiceTurn(sessionID string, gen uint64) ([]VoiceReplyFunc, bool) {
|
||
o.mu.Lock()
|
||
defer o.mu.Unlock()
|
||
leg := o.legLocked(o.states[sessionID], gen)
|
||
if leg == nil {
|
||
return nil, false
|
||
}
|
||
sinks := leg.voice.sinks
|
||
leg.voice = voiceTurn{}
|
||
return sinks, true
|
||
}
|
||
|
||
// dispatchVoiceReply 在回合结算时把结果交回语音层(协同 C1)。不是语音回合时
|
||
// 是空操作;被取代的陈旧回合同样什么都不做。
|
||
//
|
||
// 每个回调在自己的 goroutine 上跑:TTS 是网络调用,绝不许它拖住编排器的状态迁移
|
||
// (助手回复此刻已经落库,音频是它之后的增强;pending 续跑与 session_idle
|
||
// 不该等一段语音合成)。
|
||
func (o *Orchestrator) dispatchVoiceReply(sessionID string, gen uint64, res *loop.Result) {
|
||
sinks, ok := o.takeVoiceTurn(sessionID, gen)
|
||
if !ok {
|
||
return
|
||
}
|
||
for _, sink := range sinks {
|
||
go sink(res)
|
||
}
|
||
}
|