feat(gateway,loop): 忙时并行回合基础管线(I1′ 有条件放宽)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 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/**)
This commit is contained in:
AskaEth committed 2026-10-05 09:18:50 +08:00
1 parent 7d2ff74cfc
commit 89e13cf53d
23 files changed
+1921 -275

No files matched your search

@@ -77,6 +77,9 @@ func (s *Server) submitChatStream(ctx context.Context, sessionID string, req cha
"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 保持完全相同的
@@ -123,8 +126,9 @@ func (s *Server) submitChatTurn(w http.ResponseWriter, r *http.Request, req chat
// 来自会话的绑定(design/22 §4.5)。
msgs := chatTurnMessages(req.Text, req.Image, docs)
env := s.envResolver.owner(r.Context(), req.SessionID)
// 客户端的 deviceID、行为模式与可选幂等键(协同 G4)都经 ctx 串进编排器。
ctx := withClientMsgHint(withMode(withDeviceID(r.Context(), req.DeviceID), req.Mode), clientMsgHint{ClientMsgID: req.ClientMsgID})
// 客户端的 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,而不是再跑一遍
@@ -147,6 +151,13 @@ func (s *Server) submitChatTurn(w http.ResponseWriter, r *http.Request, req chat
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"))
@@ -179,11 +190,13 @@ func detachedTurnContext(parent context.Context) context.Context {
// 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 {
@@ -193,6 +206,8 @@ func (s *Server) writeChatStreamAck(w http.ResponseWriter, sr SubmitResult, sess
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})
}