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:
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user