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

+14 -4
View File
@@ -48,17 +48,24 @@ type steerTurn struct {
orchestrator *Orchestrator
sessionID string
gen uint64
consumed map[string]bool
// turnID 是本路(=本回合)的权威回合标识:`steer_ack` 的**帧壳**必须带上它
// (忙时并行规范草案 §2.5)。真并行下同一会话有两条交错的路,只按 sessionId
// 分桶的话客户端不知道这条消息被**哪一路**吃进,灰态也就配不上正确的
// `turn.end`。它在出生后不可变,因此这里存下来即可,无需在发帧时回查状态锁。
turnID string
consumed map[string]bool
}
// beginSteer 为一个刚启动的回合建一份转向游标。编排器把它挂到**该回合自己的 ctx**
// 上(loop.WithSteerSource),因此每个回合、每次续跑各有一份独立游标,上一个回合
// 已吃进的消息绝不会被下一个回合重复注入或漏掉。
func (o *Orchestrator) beginSteer(sessionID string, gen uint64) *steerTurn {
// 已吃进的消息绝不会被下一个回合重复注入或漏掉。turnID 是该路的权威回合标识,
// 与 `turn_begin` 帧壳同值(客户端靠它把 steer_ack 配到正确的 `turn.end`)。
func (o *Orchestrator) beginSteer(sessionID string, gen uint64, turnID string) *steerTurn {
return &steerTurn{
orchestrator: o,
sessionID: sessionID,
gen: gen,
turnID: turnID,
consumed: make(map[string]bool),
}
}
@@ -112,7 +119,10 @@ func (s *steerTurn) Steer(ctx context.Context, req loop.SteerRequest) []model.Me
// steer_ack:该消息已被本回合吃进、尚未被正面回答(前端据此把它渲染成
// 灰态,design/15 §5)。它携带具体消息身份,因此不进 frameReachesAll 的
// 状态机信号白名单,落 fail-closed 默认档:只发 owner + admin。
o.emitState(sessionID, wsSteerAck, map[string]any{"messageId": c.row.ID, "atStep": req.Step})
//
// 帧壳必须带本路的 turnId(忙时并行规范草案 §2.5):真并行下客户端要
// 靠它把这条消息配到**正确那一路**的 `turn.end`,否则灰态收不了口。
o.emitTurnState(sessionID, s.turnID, wsSteerAck, map[string]any{"messageId": c.row.ID, "atStep": req.Step})
out = append(out, transcriptMessage(c.row))
}
steerLog(o).Info("pending steered", "session", sessionID, "step", req.Step, "messages", len(out))