- 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,避免与契约批次打架)。
87 lines
4.3 KiB
Go
87 lines
4.3 KiB
Go
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
|
||
}
|