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

@@ -0,0 +1,292 @@
package gateway
import (
"context"
"log/slog"
"slices"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件是 design/15 §2.5.9「帧层」的回归:`turn_begin` 的发射(每次启动一路
// 恰好一帧,含续跑路)与 `steer_ack` 的帧壳 turnId。夹具复用
// session_parallel_test.go(同包)的可逐路放行 runner 与记录型状态汇聚点,因此
// 「哪一帧属于哪一路」是被观测到的事实,而不是靠睡眠赌出来的。
//
// WS 层的对应断言(真连接上的 turn_begin 角色门、interrupt{turnId} 的端到端
// 行为)在 session_parallel_turn_interrupt_test.go。
// transcriptHistory 是 fakeHistory 的转录可读变体:`Recent` 返回已落库的行。
// 裸 fakeHistory 的 Recent 恒为空(那是 TestSteer_UnpersistedPendingStaysPending
// 刻意依赖的行为,不能改),而 steer 边界的匹配**必须**读到转录行 —— 因此本
// 文件用嵌入的方式只覆盖这一个方法。
type transcriptHistory struct{ *fakeHistory }
// Recent 返回该会话最近 n 条已落库行(最旧优先),与 *history.Store 的口径一致。
func (h *transcriptHistory) Recent(_ context.Context, sessionID string, n int) ([]history.Message, error) {
rows := h.records()
out := make([]history.Message, 0, len(rows))
for _, m := range rows {
if m.SessionID == sessionID {
out = append(out, m)
}
}
if len(out) > n {
out = out[len(out)-n:]
}
return out, nil
}
// frameMessageIDs 取出一帧 `turn_begin` 的 messageIds(缺席/形状不符时返回 nil)。
func frameMessageIDs(rec turnStateRec) []string {
ids, _ := rec.data["messageIds"].([]string)
return ids
}
// dataKeys 返回帧载荷的键(升序),用于「键集恰好是这几项」的断言 —— 多一个键
// 就是契约变更,少一个键客户端就配不上。
func dataKeys(data map[string]any) []string {
out := make([]string, 0, len(data))
for k := range data {
out = append(out, k)
}
slices.Sort(out)
return out
}
// assertTurnBeginFrame 逐项断言一帧 `turn_begin`:帧壳 turnId 是该路的权威回合
// 标识,载荷键集与取值都按规范草案 §2.4 钉死。
func assertTurnBeginFrame(t *testing.T, what string, rec turnStateRec, kind string, leg, activeTurns int, turnID string, messageIDs []string) {
t.Helper()
if rec.sessionID != "web" {
t.Fatalf("%s: 帧壳 sessionId = %q, want web", what, rec.sessionID)
}
if rec.typ != EventTurnBegin {
t.Fatalf("%s: 帧类型 = %q, want %q", what, rec.typ, EventTurnBegin)
}
if rec.turnID != turnID {
t.Fatalf("%s: 帧壳 turnId = %q, want %q(必须与该路的权威 turnId 同值,否则客户端两级分桶立即失配)",
what, rec.turnID, turnID)
}
wantKeys := []string{"activeTurns", "kind", "leg", "messageIds"}
if got := dataKeys(rec.data); !slices.Equal(got, wantKeys) {
t.Fatalf("%s: 载荷键集 = %v, want %v", what, got, wantKeys)
}
if rec.data["kind"] != kind {
t.Fatalf("%s: kind = %v, want %q", what, rec.data["kind"], kind)
}
if got := stateInt(rec, "leg"); got != leg {
t.Fatalf("%s: leg = %d, want %d", what, got, leg)
}
if got := stateInt(rec, "activeTurns"); got != activeTurns {
t.Fatalf("%s: activeTurns = %d, want %d", what, got, activeTurns)
}
if got := frameMessageIDs(rec); !slices.Equal(got, messageIDs) {
t.Fatalf("%s: messageIds = %v, want %v(客户端据此把这些气泡置为灰态)", what, got, messageIDs)
}
}
// ---------------------------------------------------------------------------
// ① / 续跑:每次启动一路恰好一帧 turn_begin
// ---------------------------------------------------------------------------
// TestTurnBeginEmittedOncePerLegStarted 钉住①与续跑路:空闲启动的第一路发
// `turn_begin{kind:"turn", leg:1, activeTurns:1}` 且 messageIds 恰好是那条用户
// 消息的**权威 id**;队首续跑同样发一帧(kind 仍是 "turn",但它是一条**新路**,
// turnId 与第一路不同)。两帧的帧壳 turnId 都必须等于交给 runner 的那个 id。
func TestTurnBeginEmittedOncePerLegStarted(t *testing.T) {
t.Parallel()
f := newParallelFixture(5, 2)
first := f.o.Submit(context.Background(), "web", []model.Message{userMsg("第一问")}, loop.OwnerEnv())
if !first.Immediate || first.TurnID == "" {
t.Fatalf("第一条 = %+v, want Immediate 且带 turnId", first)
}
calls := f.run.waitStarted(1)
frames := f.sink.ofType(EventTurnBegin)
if len(frames) != 1 {
t.Fatalf("第一路的 turn_begin 帧数 = %d, want 1(恰好一次)", len(frames))
}
assertTurnBeginFrame(t, "第一路", frames[0], turnBeginKindTurn, 1, 1, first.TurnID, []string{"id-1"})
if got := loop.TurnIDFrom(calls[0].ctx); got != frames[0].turnID {
t.Fatalf("交给 runner 的 turnId = %q,turn_begin 帧壳是 %q:两处必须同值", got, frames[0].turnID)
}
// 忙时第二条(未声明并行)⇒ 入队;它的落库 id 就是续跑路的 messageIds。
second := f.o.Submit(context.Background(), "web", []model.Message{userMsg("第二问")}, loop.OwnerEnv())
if !second.Pending || second.QueueDepth != 1 {
t.Fatalf("第二条 = %+v, want Pending(1)", second)
}
f.run.release(0)
calls = f.run.waitStarted(2)
frames = f.sink.ofType(EventTurnBegin)
if len(frames) != 2 {
t.Fatalf("turn_begin 帧数 = %d, want 2(续跑也是一条新路,必须有自己的帧)", len(frames))
}
resumeTurnID := loop.TurnIDFrom(calls[1].ctx)
if resumeTurnID == first.TurnID {
t.Fatal("续跑路必须是新路:turnId 不能与上一路相同")
}
assertTurnBeginFrame(t, "续跑路", frames[1], turnBeginKindTurn, 2, 1, resumeTurnID, []string{"id-2"})
f.run.release(1)
if !waitIdle(f.o, "web") {
t.Fatal("会话没有回到空闲")
}
if got := len(f.sink.ofType(EventTurnBegin)); got != 2 {
t.Fatalf("收尾后的 turn_begin 帧数 = %d, want 2(收尾绝不再补发一帧)", got)
}
}
// ---------------------------------------------------------------------------
// ② 并行路:第二路有自己的 turn_begin(kind:"parallel")
// ---------------------------------------------------------------------------
// TestTurnBeginSecondLegIsParallelKind 钉住②:显式声明并行且照准的第二路发
// `turn_begin{kind:"parallel", leg:2, activeTurns:2}`,且它的 messageIds **只是**
// 它自己那条消息 —— 两帧各钉各的消息,客户端因此能把两条回复分到两个气泡。
func TestTurnBeginSecondLegIsParallelKind(t *testing.T) {
t.Parallel()
f := newParallelFixture(5, 2)
first := f.o.Submit(context.Background(), "web", []model.Message{userMsg("第一问")}, loop.OwnerEnv())
f.run.waitStarted(1)
second := f.o.Submit(parallelCtx(), "web", []model.Message{userMsg("第二问")}, loop.OwnerEnv())
if !second.Immediate || !second.Parallel {
t.Fatalf("显式并行提交 = %+v, want Immediate 且 Parallel", second)
}
calls := f.run.waitStarted(2)
waitActiveTurns(t, f.o, "web", 2)
frames := f.sink.ofType(EventTurnBegin)
if len(frames) != 2 {
t.Fatalf("turn_begin 帧数 = %d, want 2(两条路各一帧)", len(frames))
}
assertTurnBeginFrame(t, "第一路", frames[0], turnBeginKindTurn, 1, 1, first.TurnID, []string{"id-1"})
assertTurnBeginFrame(t, "第二路", frames[1], turnBeginKindParallel, 2, 2, second.TurnID, []string{"id-2"})
if got := loop.TurnIDFrom(calls[1].ctx); got != frames[1].turnID {
t.Fatalf("第二路交给 runner 的 turnId = %q,帧壳是 %q:两处必须同值", got, frames[1].turnID)
}
f.run.release(1)
f.run.release(0)
if !waitIdle(f.o, "web") {
t.Fatal("会话没有回到空闲")
}
}
// ---------------------------------------------------------------------------
// ③ steer_ack 的帧壳 turnId
// ---------------------------------------------------------------------------
// TestSteerAckFrameCarriesLegTurnID 钉住③:被转向吃进的那条消息,其
// `steer_ack` **帧壳**必须带吃进它的那一路的 turnId(真并行下客户端靠它把灰态
// 配到正确那一路的 `turn.end`)。这里直接用该路挂在 ctx 上的转向源驱动边界 ——
// 与 loop 在回合内边界调用的是同一个对象。
func TestSteerAckFrameCarriesLegTurnID(t *testing.T) {
t.Parallel()
run := newGatedLegRunner()
sink := &turnSink{}
o := NewOrchestrator(OrchestratorOptions{
Run: run.Run,
Emit: sink.Emit,
EmitTurn: sink.EmitTurn,
Log: slog.New(slog.NewTextHandler(discardWriter{}, nil)),
MaxPending: 5,
MaxTail: 20,
History: &transcriptHistory{fakeHistory: &fakeHistory{}},
MaxParallelTurns: 2,
})
first := o.Submit(context.Background(), "web", []model.Message{userMsg("第一条")}, loop.OwnerEnv())
if !first.Immediate {
t.Fatalf("第一条 = %+v, want Immediate", first)
}
calls := run.waitStarted(1)
if sr := o.Submit(context.Background(), "web", []model.Message{userMsg("忙时新消息")}, loop.OwnerEnv()); !sr.Pending {
t.Fatalf("忙时第二条 = %+v, want Pending", sr)
}
src := loop.SteerSourceFrom(calls[0].ctx)
if src == nil {
t.Fatal("本路的 ctx 上没有挂转向源")
}
steered := src.Steer(calls[0].ctx, loop.SteerRequest{SessionID: "web", Step: 2})
if len(steered) != 1 || messageText(steered[0]) != "忙时新消息" {
t.Fatalf("注入 = %v, want [忙时新消息]", windowText(steered))
}
acks := sink.ofType(wsSteerAck)
if len(acks) != 1 {
t.Fatalf("steer_ack 帧数 = %d, want 1", len(acks))
}
if acks[0].turnID != first.TurnID {
t.Fatalf("steer_ack 帧壳 turnId = %q, want %q(= 吃进它的那一路的 turnId)", acks[0].turnID, first.TurnID)
}
if acks[0].data["messageId"] != "id-2" || stateInt(acks[0], "atStep") != 2 {
t.Fatalf("steer_ack 载荷 = %v, want {messageId:id-2, atStep:2}", acks[0].data)
}
run.release(0)
if !waitIdle(o, "web") {
t.Fatal("会话没有回到空闲")
}
}
// ---------------------------------------------------------------------------
// ④ interrupt{turnId} 只停目标路
// ---------------------------------------------------------------------------
// TestInterruptTurnStopsOnlyTargetLeg 钉住④的编排器侧:`InterruptTurn` 只取消
// 目标那一路 —— 另一路照常跑完并落库,被取消的那一路不补说明(现状语义),
// session_interrupted 如实报 cancelledTurns=1 / dropped=0,且在另一路仍在飞时
// **不报空闲**。
func TestInterruptTurnStopsOnlyTargetLeg(t *testing.T) {
t.Parallel()
f := newParallelFixture(5, 2)
first := f.o.Submit(context.Background(), "web", []model.Message{userMsg("第一问")}, loop.OwnerEnv())
if !first.Immediate || first.TurnID == "" {
t.Fatalf("第一路 = %+v, want Immediate 且带 turnId", first)
}
f.run.waitStarted(1)
second := f.o.Submit(parallelCtx(), "web", []model.Message{userMsg("第二问")}, loop.OwnerEnv())
if !second.Parallel {
t.Fatalf("第二路 = %+v, want Parallel", second)
}
f.run.waitStarted(2)
waitActiveTurns(t, f.o, "web", 2)
if !f.o.InterruptTurn("web", second.TurnID) {
t.Fatalf("InterruptTurn(%q) = false, want true(该路确实在途)", second.TurnID)
}
waitActiveTurns(t, f.o, "web", 1)
intr := f.sink.ofType(EventSessionInterrupt)
if len(intr) != 1 {
t.Fatalf("session_interrupted 帧数 = %d, want 1", len(intr))
}
if got := stateInt(intr[0], "cancelledTurns"); got != 1 {
t.Fatalf("cancelledTurns = %d, want 1(只停了一路)", got)
}
if got := stateInt(intr[0], "dropped"); got != 0 {
t.Fatalf("dropped = %d, want 0(目标化打断不碰待处理队列)", got)
}
if got := f.sink.ofType(EventSessionIdle); len(got) != 0 {
t.Fatalf("另一路仍在飞 ⇒ 不该报空闲,得到 %v", got)
}
// 另一路(第一路)照常跑完:它的助手回复必须落库,而被取消的第二路不补说明。
f.run.release(0)
if !waitIdle(f.o, "web") {
t.Fatal("另一路收尾后会话没有回到空闲")
}
if got := assistantContents(f.history.records()); !slices.Equal(got, []string{"reply-1"}) {
t.Fatalf("助手回复 = %v, want [reply-1](另一路继续作答,被取消路不补说明)", got)
}
}