Files
AskaEth 89e13cf53d
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway,loop): 忙时并行回合基础管线(I1′ 有条件放宽)
- 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/**)
2026-10-05 09:18:50 +08:00

119 lines
4.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}