feat(gateway): 忙时并行帧层(turn_begin + steer_ack.turnId + interrupt.turnId)

新下行帧 turn_begin:编排器每启动一路恰好一帧(单路 kind:"turn" / 并行路 "parallel"),
data{kind, messageIds, leg, activeTurns};角色门 fail-closed 仅 owner+admin,未进 frameReachesAll。
steer_ack 增帧壳 turnId(与该路 turn_begin 同源)—— 并行下没有它配不上正确的 turn.end。
上行 interrupt.turnId(仅 scope:"current" 生效):精确停某一路;目标不存在 ⇒ 定向
{type:"error",data:{code:"bad_request"}},不广播、不动任何会话;省略 turnId = v0.65 原口径(全部在途路)。
帧序按实现:session_busy / pending_drain → message_persisted(user) → turn_begin
(规范草案 §2.9 的时序图需按实现更正:messageIds 必须是已落库的权威 id,故只能在落库之后)。
测试:编排器侧 + 真 WS/JWT 端到端;变异 5 条(每路一帧 / 帧壳 turnId / 目标化打断 / 取消筛选)。
Docs 待升 v0.66(由监工登记)。
This commit is contained in:
AskaEth committed 2026-10-05 09:38:11 +08:00
1 parent e5cc9c6054
commit c0dc435770
13 files changed
+800 -45

No files matched your search

+31 -1
View File
@@ -103,7 +103,7 @@ func (h *hub) streamChat(ctx context.Context, payload any) SubmitResult {
func (h *hub) runTurn(ctx context.Context, sessionID string, msgs []model.Message, env loop.SessionEnv) (*loop.Result, error) {
// 该路的 turnId 优先取 ctx 上已有的那个(design/15 §2.5.9 的 ⚠️ 必改项):
// 编排器在**启动该路之前**就生成了它,并把同一份 id 交给 runTurn —— 因为它
// 发出(后续切片的)`turn_begin` 帧时必须报出这个 id,而客户端按 turnId
// 发出 `turn_begin` 帧时必须报出这个 id,而客户端按 turnId
// 两级分桶。若这里再自己生成一个,两处 id 不同,分桶会立即失配。
//
// 未挂载(子代理 / 后台思考 / 未接编排器的旧回退路径)时才新铸一个,因此
@@ -149,6 +149,14 @@ type interruptRequest struct {
// 其余取值一律显式拒绝 —— 客户端以为队列保住了、实际被清空,
// 就是静默丢消息,正是 A2 要修掉的那类缺陷。
Scope string `json:"scope,omitempty"`
// TurnID 是「只停这一路」的目标(忙时并行规范草案 §2.3):**只在
// scope:"current" 时生效**;省略 scope 或 scope:"all" 时按契约**忽略**它
// ——那两个取值的语义本就是「全部在途路 + 清 pending」。
//
// 指向不存在的路(已被收尾 / 已被取消 / id 写错)⇒ 定向回
// `{type:"error",data:{code:"bad_request"}}`,**绝不**静默降级成「停全部」:
// 那会把用户「只停这一路」的意图执行成「停掉所有路」。
TurnID string `json:"turnId,omitempty"`
}
// parseInterruptScope 把上行 scope 解析为取值域内的口径。省略、空串与
@@ -178,6 +186,10 @@ func parseInterruptScope(raw string) (InterruptScope, error) {
// 分发到此(design/11 §7.2 B)。
//
// 缺 sessionId 时是空操作(既有行为,本次不改)。
//
// 目标化打断(忙时并行规范草案 §2.3):`scope:"current"` + `turnId` ⇒ 只停那
// 一路(InterruptTurn)。其它 scope 下 turnId 按契约**忽略**(它们的语义本就是
// 「全部在途路 + 清队列」),这里只留一条 Debug 让「发了但没生效」可被追溯。
func (c *wsConn) handleInterrupt(payload json.RawMessage) {
if c.h == nil {
return
@@ -193,9 +205,27 @@ func (c *wsConn) handleInterrupt(payload json.RawMessage) {
if c.h.orchestrator == nil || req.SessionID == "" {
return
}
if req.TurnID != "" && scope != InterruptScopeCurrent {
c.h.log.Debug("ws interrupt turnId ignored", "session", req.SessionID, "scope", scope)
}
if req.TurnID != "" && scope == InterruptScopeCurrent {
c.interruptOneTurn(req.SessionID, req.TurnID)
return
}
c.h.orchestrator.InterruptScope(req.SessionID, scope)
}
// interruptOneTurn 执行一次「只停这一路」并如实回报结果:目标不存在时定向回
// 发送者一条 {type:"error", data:{code:"bad_request"}}(只到发送者、不广播、也
// 不动任何会话),**绝不**静默降级成「停掉全部在途路」。
func (c *wsConn) interruptOneTurn(sessionID, turnID string) {
if c.h.orchestrator.InterruptTurn(sessionID, turnID) {
return
}
c.h.log.Debug("ws interrupt turn not found", "session", sessionID, "turn", turnID)
c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"code": wsBadRequestCode}})
}
// broadcastStream 把流式的 model.StreamEvent 映射为其下行 WS 事件类型,
// 并经 hub 的帧门扇出(design/11 §7.1):每一个结构化流帧都携带模型
// 输出,因此只到达 owner + admin。它跑在 bus 订阅 worker 上;seq 原子