Files
Cyrene_v2/internal/services/gateway/session_reject.go
T
AskaEth 3a01b4e236
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway): 语音回合进编排+落库、G5 队列满带回身份、voice_output 归属(安卓 G5/C1/C3)
- G5:pending_full 增 clientMsgId/messageId(仅在可给出时出现、绝不新铸 id —— 队列满的拒绝发生在幂等键认领之前,代铸会让客户端重试被判重复而永远进不来);另定向回发送者 {type:error,data:{code:chat_busy,...}}(与 REST 409 chat_busy 同码),广播帧照旧带同样的键供其它端对齐。

- C1:语音回合改走与文本同一条路径(编排器 Submit → 落库 → 流式 → 跨端同步),deviceId 走既有 withDeviceID、platform 走存储默认 web;未接编排器时保留旧直连路径。音频在回合结算处(persistAssistantReply → persistDocumentStatus → dispatchVoiceReply)合成并定向回原连接,回调各自 goroutine(TTS 延迟不拖住 pending_drain/session_idle);续跑 FIFO 与转向注入两条路径都带走回调 ⇒ 忙时排队/被注入的语音也不丢音频。

- C3:voice_output 增 sessionId/deviceId;三处主动加码(按最优方向):① voice_input 增可选 clientMsgId(语音也有幂等)② 不再拿 stopReason 顶替空正文当语音内容(空正文走 provider 降级信号)③ TTS 失败照旧回帧 + provider:silent(不静默)。

- 拆 chat_documents_test.go(510 行越界)→ 373 + 160,只搬不改(拆前后同 -run 过滤 PASS 集合逐条一致)。

- 主会话独立验收:隔离 worktree(HEAD + 本批次 11 文件)build/vet 绿、gateway -race 绿、全树 59 包(唯一红是既有负载 flake orchestration/TestSpawnTimeoutFromToolDefault)、linux+darwin 交叉编译绿;变异独立复现(pending_full 摘掉 clientMsgId ⇒ 3 条红)。

- 待办(已登记):Docs/api-client.md 与 api-reference.md 的帧/字段登记随 v0.59 一并落笔(本批次刻意未动 Docs,避免与契约批次打架)。
2026-10-04 17:59:28 +08:00

87 lines
4.3 KiB
Go
Raw 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"
// 本文件归属协同项 G5 的「队列满的拒绝要说清是哪一条」:pending_full 状态帧的
// 载荷回填,以及发往**发送者连接**的那条定向拒绝帧。
//
// 背景:pending_full 此前只带 {sessionId, mode, max},于是客户端收到「队列满」
// 时无法判定该收掉哪一条本地乐观气泡 —— 那条气泡会永远挂在「发送中…」(REST
// 路径有 409 chat_busy 可以回滚,WS 路径没有等价物)。这里给出确定的键,并让
// 发送者收到一条能精确处理的定向帧。
//
// 它与 ws 边界的关系:编排器不认识连接,只认识 ctx 上由传输层给出的「怎么把
// 拒绝送回发送者」回调(与 deviceID / mode / 幂等键同一种机制)。REST 路径不接
// 回调 —— 它的 409 chat_busy 承担同一职责。
// PendingFullNotifier 把「本次提交因待处理队列已满被拒」定向通知给发送者。
// 它只作用于发起这条消息的那根连接:广播的 pending_full 是状态信号,各端自行
// 按 clientMsgId 对齐,其它连接忽略即可。clientMsgID 与 messageID 都可能为 ""
// (该键省略,绝不伪造)。
type PendingFullNotifier func(sessionID, clientMsgID, messageID string)
// pendingFullNotifierKey 是携带定向拒绝回调的 context 键。它从 WS 帧边界
// (ws_route.go 的 submitChat / voice.go 的 runVoiceTurn)引入编排器。
type pendingFullNotifierKey struct{}
// withPendingFullNotifier 返回携带定向拒绝回调的 context。nil 回调是空操作:
// 未接线传输层(REST / 单元测试)的提交在 ctx 上不留任何痕迹。
func withPendingFullNotifier(ctx context.Context, n PendingFullNotifier) context.Context {
if n == nil {
return ctx
}
return context.WithValue(ctx, pendingFullNotifierKey{}, n)
}
// pendingFullNotifierFrom 返回 ctx 携带的定向拒绝回调,未设置时为 nil。
func pendingFullNotifierFrom(ctx context.Context) PendingFullNotifier {
n, _ := ctx.Value(pendingFullNotifierKey{}).(PendingFullNotifier)
return n
}
// emitPendingFull 广播 pending_full 状态帧,并在接线了发送者回调时定向回一条
// 拒绝帧。调用方必须已从状态锁退出(状态锁内不做广播,与既有纪律一致)。
func (o *Orchestrator) emitPendingFull(ctx context.Context, sessionID string, mode Mode, rejected clientMsgHint) {
o.emitState(sessionID, EventPendingFull, pendingFullPayload(sessionID, mode, o.maxPending, rejected))
if n := pendingFullNotifierFrom(ctx); n != nil {
n(sessionID, rejected.ClientMsgID, rejected.MessageID)
}
}
// rejectedIdentityLocked 为队列满被拒的那条消息取「服务端能给出的身份」(G5):
// clientMsgId 原样来自 ctx(省略时为空 ⇒ 该字段被省略);messageId 只在它**此前
// 已经落库或已认领**时给出。
//
// 它绝不新铸 id:队列满的拒绝发生在认领之前,一旦在这里认领一个新 id,客户端用
// 同一个键重试就会被判成「重复到达」而永远进不来(见 admitLocked 的顺序说明)。
// 因此未落库时只报 clientMsgId。必须在 o.mu 下调用。
func (o *Orchestrator) rejectedIdentityLocked(ctx context.Context, st *sessionState, sessionID string) clientMsgHint {
id := clientMsgIDFrom(ctx)
if id == "" {
return clientMsgHint{}
}
if msgID, ok := st.clientMsgs[id]; ok {
return clientMsgHint{ClientMsgID: id, MessageID: msgID}
}
if msgID, ok := o.findPersistedClientMsg(ctx, sessionID, id); ok {
o.rememberClientMsgLocked(st, id, msgID)
return clientMsgHint{ClientMsgID: id, MessageID: msgID}
}
return clientMsgHint{ClientMsgID: id}
}
// pendingFullPayload 组装 pending_full 状态帧的载荷。既有三个键
// (sessionId / mode / max)**一个不改**,新增键是纯增量:
// - clientMsgId:被拒那条消息的客户端幂等键(未携带时该键不出现);
// - messageId:该键此前已落库/已认领时的消息 id(未落库时该键不出现)。
func pendingFullPayload(sessionID string, mode Mode, max int, rejected clientMsgHint) map[string]any {
data := map[string]any{"sessionId": sessionID, "mode": mode, "max": max}
if rejected.ClientMsgID != "" {
data["clientMsgId"] = rejected.ClientMsgID
}
if rejected.MessageID != "" {
data["messageId"] = rejected.MessageID
}
return data
}