- 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/**)
215 lines
11 KiB
Go
215 lines
11 KiB
Go
package gateway
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"net/http"
|
||
"time"
|
||
|
||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||
)
|
||
|
||
// 本文件归属 REST `POST /api/v1/chat` 的派发:`stream:true`(Android 协同 §G2)
|
||
// 与默认的同步回合(submitChatTurn)两条路径。
|
||
//
|
||
// 修复前的链路是 publishChatStream → bus.Publish → hub 订阅 → streamChat,三处
|
||
// 叠加起来让「客户端拿到 200、却什么都没发生」成为可能:
|
||
// ① bus 的订阅缓冲满时会**丢事件**(bus 包文档明示的代价),而会话消息被丢就是
|
||
// 用户消息消失;
|
||
// ② handler 在问编排器之前就回了 200 {"stream":true};
|
||
// ③ Publish 的错误被丢弃,且传的是**请求作用域**的 ctx(请求返回后即被取消,
|
||
// Publish 直接返回)。
|
||
//
|
||
// 修复后它与 WS 的 chat 帧走**同一条直达路径**(ws_route.go 的既有先例,
|
||
// design/15 §2.1「会话消息不得被 drop」):接了编排器就直接提交,完全不经总线。
|
||
// handler 因此在回 200 之前就知道这一轮真的被接收并落库。只有在没接编排器时才
|
||
// 回退到总线发布,而发布失败不再被吞掉 —— 回 503 + 明确错误码,绝不假装成功。
|
||
|
||
// chatStreamTurnBudget 是 stream:true 派生回合上下文的上限。该上下文必须与请求
|
||
// 作用域**脱钩**:handler 返回后 r.Context() 即被取消,若回合挂在它下面,流式
|
||
// 回合会在客户端刚收到 200 时被连带取消(回合是提交之后在后台跑的)。因此基础
|
||
// 用 context.WithoutCancel(保留 trace/审计等值、丢掉取消信号),再套一个上限。
|
||
//
|
||
// 上限取一个远大于任何正常回合的值:工具循环上限 8 轮(loop.DefaultMaxRounds),
|
||
// 正常一轮是秒级到分钟级,30 分钟只在回合真的挂死时兜底 —— 它是安全网,不是
|
||
// 功能限制(与 WS 路径的 context.Background() 相比,只是多了一个上界)。
|
||
const chatStreamTurnBudget = 30 * time.Minute
|
||
|
||
// handleChatStream 是 POST /api/v1/chat 的 stream:true 分支。它先把这一轮交给
|
||
// submitChatStream(与 WS chat 帧同一条直达路径),拿到判定后才回响应,因此
|
||
// **200 意味着消息已被接住并落库**;派发用的 ctx 与请求作用域脱钩、带上限
|
||
// (见 detachedTurnContext)。
|
||
func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request, req chatRequest) {
|
||
if s.runner == nil {
|
||
// 与同步路径同一口径:没接 loop 就不能假装受理了一轮对话(修复前这里
|
||
// 照样回 200,客户端永远等不到任何帧)。
|
||
writeError(w, apperr.Wrap(apperr.ErrInternal, "chat_unavailable", "chat service is not configured"))
|
||
return
|
||
}
|
||
sid := req.SessionID
|
||
if sid == "" {
|
||
sid = defaultSessionID
|
||
}
|
||
sr, err := s.submitChatStream(detachedTurnContext(r.Context()), sid, req)
|
||
if err != nil {
|
||
// 派发没成功:明确报 503,而不是让客户端以为这一轮在跑(协同 G2)。
|
||
s.log.Error("chat stream dispatch", "err", err, "session", sid)
|
||
writeError(w, apperr.Wrap(apperr.ErrTimeout, "chat_dispatch_failed", "流式通道不可用,本轮未提交;请重试"))
|
||
return
|
||
}
|
||
s.writeChatStreamAck(w, sr, sid)
|
||
}
|
||
|
||
// submitChatStream 把一次 stream:true 的对话交给编排器,并把它对本次提交的判定
|
||
// 交回 HTTP 层(协同 G2)。error 非 nil 表示这一轮**没有**被接收,此时调用方
|
||
// 绝不能回 2xx。
|
||
//
|
||
// 它接收整个 chatRequest(而不是逐个字段),使新增的可选字段(documents,
|
||
// design/24 §6.1)自动随载荷下传 —— 少抄一个字段就是一次静默丢附件。
|
||
func (s *Server) submitChatStream(ctx context.Context, sessionID string, req chatRequest) (SubmitResult, error) {
|
||
payload := map[string]any{
|
||
"sessionId": sessionID,
|
||
"text": req.Text,
|
||
"image": req.Image,
|
||
"deviceId": req.DeviceID,
|
||
"mode": req.Mode,
|
||
"clientMsgId": req.ClientMsgID,
|
||
"documents": req.Documents,
|
||
// parallel 与 WS chat 帧**同形**(design/15 §2.5.2):三条入口
|
||
//(REST 同步 / REST stream / WS chat)不能对同一条消息有不同口径。
|
||
"parallel": req.Parallel,
|
||
}
|
||
if s.hub == nil || s.hub.orchestrator == nil {
|
||
// 未接编排器:回退到既有的总线触发路径(未接线的 hub 保持完全相同的
|
||
// ws.chat 契约),但发布失败与「没有总线」都不再是静默成功。
|
||
return s.publishChatStream(ctx, payload)
|
||
}
|
||
// 直达编排器:streamChat 同步返回提交判定(真正的回合跑在它自己派生的
|
||
// goroutine 上),因此这里不会阻塞在模型调用上。
|
||
return s.hub.streamChat(ctx, payload), nil
|
||
}
|
||
|
||
// publishChatStream 是 stream:true 的兜底派发:把载荷发布到 ws.chat 主题,由 hub
|
||
// 的中继消费。它只在未接编排器时被用到,并且**把错误交回调用方** —— 修复前它吞掉
|
||
// 了 Publish 的错误,于是总线关闭/ctx 已取消时客户端照样拿到 200。
|
||
func (s *Server) publishChatStream(ctx context.Context, payload map[string]any) (SubmitResult, error) {
|
||
if s.bus == nil {
|
||
return SubmitResult{}, fmt.Errorf("gateway: chat stream has neither an orchestrator nor a bus: %w", apperr.ErrInternal)
|
||
}
|
||
if err := s.bus.Publish(ctx, wsChatSubject, payload); err != nil {
|
||
return SubmitResult{}, fmt.Errorf("gateway: publish %s: %w", wsChatSubject, err)
|
||
}
|
||
// 回退路径只能确认「已交给总线」,不能确认编排器收下了:如实按既有契约
|
||
// 当已接受处理(2xx),并且已由调用方记日志。
|
||
return SubmitResult{Accepted: true, Immediate: true}, nil
|
||
}
|
||
|
||
// submitChatTurn 是 `POST /api/v1/chat` 默认(非 stream)路径的派发:把一个已
|
||
// 校验装配好的回合交给编排器跑完,并把它对本次提交的判定写成响应。它是
|
||
// submitChatStream 的同步孪生 —— 两者只差「等回合跑完再回」与「立刻回提交判定」,
|
||
// 因此都住在同一个派发文件里,且共用同一条「接了编排器就直达、否则回退」的口径。
|
||
//
|
||
// 它由 handleChat 的尾段原样搬来(零逻辑改动,只把装配好的文档一起接过来),
|
||
// 使 handleChat 回到「解码 → 校验 → 装配 → 派发」的薄壳形态(函数长度上限)。
|
||
func (s *Server) submitChatTurn(w http.ResponseWriter, r *http.Request, req chatRequest, docs []model.Document) {
|
||
if s.runner == nil {
|
||
writeError(w, apperr.Wrap(apperr.ErrInternal, "chat_unavailable", "chat service is not configured"))
|
||
return
|
||
}
|
||
if req.SessionID == "" {
|
||
req.SessionID = "web"
|
||
}
|
||
// Web 主会话是主人的私密对话(JWT = owner),因此它绑定
|
||
// 全访问 env:记忆读+写,行为不变。角色
|
||
// 来自会话的绑定(design/22 §4.5)。
|
||
msgs := chatTurnMessages(req.Text, req.Image, docs)
|
||
env := s.envResolver.owner(r.Context(), req.SessionID)
|
||
// 客户端的 deviceID、行为模式与可选幂等键(协同 G4)都经 ctx 串进编排器;
|
||
// 可选的并行声明同理(design/15 §2.5)。
|
||
ctx := withParallelTurn(withClientMsgHint(withMode(withDeviceID(r.Context(), req.DeviceID), req.Mode), clientMsgHint{ClientMsgID: req.ClientMsgID}), req.Parallel)
|
||
if s.hub.orchestrator != nil {
|
||
sr, res := s.hub.orchestrator.SubmitSync(ctx, req.SessionID, msgs, env)
|
||
// 重复到达(同一个 clientMsgId):明确告知既有消息 id,而不是再跑一遍
|
||
// 回合、再落一条用户消息(协同 G4)。
|
||
if sr.Duplicate {
|
||
writeJSON(w, http.StatusOK, map[string]any{"duplicate": true, "messageId": sr.MessageID, "sessionId": req.SessionID})
|
||
return
|
||
}
|
||
if !sr.Accepted {
|
||
if sr.PendingFull {
|
||
writeError(w, apperr.Wrap(apperr.ErrConflict, "chat_busy", "会话正忙,待处理消息已满"))
|
||
} else {
|
||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "text、image 或 documents 至少需要一项"))
|
||
}
|
||
return
|
||
}
|
||
// 忙碌会话返回非阻塞 ack(design/15 §2.1),而不是
|
||
// 让请求阻塞在一个可能跑一阵子的回合上。
|
||
if sr.Pending {
|
||
writeJSON(w, http.StatusAccepted, map[string]any{"queued": true, "queueDepth": sr.QueueDepth})
|
||
return
|
||
}
|
||
// 升为独立一路(design/15 §2.5):第二路在后台跑,本次请求拿不到同步
|
||
// 回复,因此同样回 202 + 明确的判定 —— 绝不回一个 res 为 nil 的 200
|
||
//(那会让客户端以为拿到了空回复)。
|
||
if sr.Parallel {
|
||
writeJSON(w, http.StatusAccepted, map[string]any{"parallel": true, "turnId": sr.TurnID})
|
||
return
|
||
}
|
||
if sr.Err != nil {
|
||
s.log.Error("chat run", "err", sr.Err, "session", req.SessionID)
|
||
writeError(w, apperr.Wrap(apperr.ErrInternal, "chat_failed", "chat failed"))
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, chatResponse{Reply: res.Text, Steps: res.Steps, StopReason: res.StopReason})
|
||
return
|
||
}
|
||
// 未接线编排器时的旧同步路径(未接线的 hub
|
||
// 保持完全与 design/15 之前相同的回复契约)。
|
||
res, err := s.runner.Run(loop.WithSessionEnv(r.Context(), env), req.SessionID, msgs)
|
||
if err != nil {
|
||
s.log.Error("chat run", "err", err, "session", req.SessionID)
|
||
writeError(w, apperr.Wrap(apperr.ErrInternal, "chat_failed", "chat failed"))
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, chatResponse{Reply: res.Text, Steps: res.Steps, StopReason: res.StopReason})
|
||
}
|
||
|
||
// detachedTurnContext 返回一个与请求作用域脱钩、带上限的回合上下文(见
|
||
// chatStreamTurnBudget)。它刻意**不**把 cancel 交出去:回合活过这次 HTTP 请求,
|
||
// 在这里取消父 ctx 会连坐取消在途回合 —— cancel 只是「提前释放计时器」的优化,
|
||
// 上限到点自会释放,因此显式丢弃并写明原因(vet 的 lostcancel 检查也要求把
|
||
// cancel 用在某个地方)。
|
||
func detachedTurnContext(parent context.Context) context.Context {
|
||
ctx, cancel := context.WithTimeout(context.WithoutCancel(parent), chatStreamTurnBudget)
|
||
_ = cancel
|
||
return ctx
|
||
}
|
||
|
||
// writeChatStreamAck 把 stream:true 的提交判定写成响应(协同 G2 顺带项 + G4)。
|
||
// 此前它无条件回 200 {"stream":true},于是「排队满」与「重复到达」都只能靠客户端
|
||
// 猜;现在五种判定各有明确的状态码与载荷:
|
||
// - 重复到达:200 {duplicate:true, messageId}(没有新回合、没有新帧,客户端用
|
||
// messageId 把本地乐观气泡对齐到既有消息)
|
||
// - 待处理队列满:409 {error:{code:"chat_busy"}}(与同步路径同一口径)
|
||
// - 会话正忙、已续接排队:202 {stream:true, queued:true, queueDepth}
|
||
// - 已升为独立一路:200 {stream:true, parallel:true, turnId}(design/15 §2.5;
|
||
// 该路的帧随后经 WS 到达,turnId 就是它的权威标识)
|
||
// - 已开始:200 {stream:true}
|
||
func (s *Server) writeChatStreamAck(w http.ResponseWriter, sr SubmitResult, sessionID string) {
|
||
switch {
|
||
case sr.Duplicate:
|
||
writeJSON(w, http.StatusOK, map[string]any{"duplicate": true, "messageId": sr.MessageID, "sessionId": sessionID})
|
||
case sr.PendingFull:
|
||
writeError(w, apperr.Wrap(apperr.ErrConflict, "chat_busy", "会话正忙,待处理消息已满"))
|
||
case sr.Pending:
|
||
writeJSON(w, http.StatusAccepted, map[string]any{"stream": true, "queued": true, "queueDepth": sr.QueueDepth})
|
||
case sr.Parallel:
|
||
writeJSON(w, http.StatusOK, map[string]any{"stream": true, "parallel": true, "turnId": sr.TurnID})
|
||
default:
|
||
writeJSON(w, http.StatusOK, map[string]any{"stream": true})
|
||
}
|
||
}
|