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

@@ -286,6 +286,10 @@ func assertSteerExactlyOnce(t *testing.T, store *history.Store) {
// assertSteerAckFrames 断言 design/15 §5 的帧壳/载荷键集/角色门:owner 与 admin 各
// 收到恰好一帧 steer_ack(messageId = 被吃进那条的权威 id、atStep = 2),viewer 收不到
// (负例以白名单哨兵确实到达它为前提,否则「什么都没收到」不可判定)。
//
// 同时断言帧层的两件事(design/15 §2.5.9):这一路恰好一帧 `turn_begin`(把本路与
// 第一条用户消息钉死),且 steer_ack 的**帧壳 turnId** 与它同值 —— 真并行下客户端
// 就是靠这个值把灰态配到正确的 `turn.end`。
func assertSteerAckFrames(t *testing.T, f *steerFixture) {
t.Helper()
if err := f.bus.Publish(context.Background(), wsPrefix+scopeSentinel, map[string]any{"sessionId": "web"}); err != nil {
@@ -298,15 +302,47 @@ func assertSteerAckFrames(t *testing.T, f *steerFixture) {
for _, role := range []auth.Role{auth.RoleOwner, auth.RoleAdmin} {
frames := drainFrames(t, f.conns[role])
assertFrameCount(t, string(role), frames, wsSteerAck, 1)
assertSteerAckFrame(t, string(role), findFrame(t, string(role), frames, wsSteerAck), users[1])
ack := findFrame(t, string(role), frames, wsSteerAck)
assertSteerAckFrame(t, string(role), ack, users[1])
assertTurnBeginBindsAck(t, string(role), frames, ack, users[0])
}
viewerFrames := drainFrames(t, f.conns[auth.RoleViewer])
assertFrameCount(t, string(auth.RoleViewer), viewerFrames, wsSteerAck, 0)
// turn_begin 携带消息身份 ⇒ fail-closed 默认档,viewer 同样收不到。
assertFrameCount(t, string(auth.RoleViewer), viewerFrames, EventTurnBegin, 0)
if frameCountOf(viewerFrames, scopeSentinel) == 0 {
t.Fatalf("viewer 未收到白名单哨兵 %q,负例不可判定(收到的帧:%v)", scopeSentinel, frameTypesOf(viewerFrames))
}
}
// assertTurnBeginBindsAck 断言本路的 `turn_begin` 与其 `steer_ack` 在帧层对得上:
// 帧壳 turnId 同值、kind/leg/activeTurns 取规范草案 §2.4 的值、messageIds 是这一路
// 归属的那条用户消息的权威 id。
func assertTurnBeginBindsAck(t *testing.T, role string, frames []subagentFrame, ack subagentFrame, owner history.Message) {
t.Helper()
assertFrameCount(t, role, frames, EventTurnBegin, 1)
begin := findFrame(t, role, frames, EventTurnBegin)
if begin.TurnID == "" || begin.TurnID != ack.TurnID {
t.Fatalf("%s: turn_begin 帧壳 turnId = %q,steer_ack 的 = %q:两者必须同值(客户端靠它把灰态配到正确的 turn.end)",
role, begin.TurnID, ack.TurnID)
}
var data struct {
Kind string `json:"kind"`
MessageIDs []string `json:"messageIds"`
Leg int `json:"leg"`
ActiveTurns int `json:"activeTurns"`
}
if err := json.Unmarshal(begin.Data, &data); err != nil {
t.Fatalf("%s: turn_begin data = %s: %v", role, begin.Data, err)
}
if data.Kind != turnBeginKindTurn || data.Leg != 1 || data.ActiveTurns != 1 {
t.Fatalf("%s: turn_begin = %+v, want {kind:turn, leg:1, activeTurns:1}", role, data)
}
if len(data.MessageIDs) != 1 || data.MessageIDs[0] != owner.ID {
t.Fatalf("%s: turn_begin messageIds = %v, want [%s](本路归属的那条用户消息)", role, data.MessageIDs, owner.ID)
}
}
// frameCountOf 统计角色收到的某类型帧条数。
func frameCountOf(frames []subagentFrame, typ string) int {
n := 0