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

215 lines
11 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"
"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})
}
}