缺口:「1 普通回合 + 1 并行路」时普通回合既不登记也不被检查 —— 因为互斥身份取自 loop.ParallelLegFrom,而网关只给 parallel=true 那一路盖它。直接给普通回合盖 WithParallelLeg 会把它的 checkpoint 键从 sess 变成 sess#turn-x(改崩溃恢复语义),故改为两条独立通道: - 工具互斥身份:loop.WithToolLegID / ToolLegIDFrom(新增),网关在 beginLegLocked 内【无条件】盖, 取值 = 本路 turnID ⇒ 普通回合 / 队首续跑路 / 并行路 三条路都覆盖 - checkpoint 归属:WithParallelLeg 一行未改,仍只有真并行路 ⇒ 恢复语义零变化 守门断言 TestCheckpointOwnerIgnoresToolLegID:普通回合 ctx 下 AgentID 必须逐字仍是 sess。 变异 3 条:互斥身份回退只认 ParallelLegFrom(判忙失效)/ 顺手把 checkpoint 改读新通道 (守门断言红,证明确实解耦)/ 普通回合不盖身份(判忙失效)。 另修 race:-race -count=2 三包全绿;诚实边界含一个既有竞态(同一路并发登记共享,建议后续小切片)。
541 lines
20 KiB
Go
541 lines
20 KiB
Go
package gateway
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"log/slog"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
"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「忙时并行回合(多路)」**基础管线**的回归套件
|
||
//(主人 2026-10-05 裁决 B)。它钉住六件事:
|
||
//
|
||
// ① 省略 `parallel` ⇒ 行为逐字不变(不并行、不新增帧键、报空闲时点不变);
|
||
// ② 显式 `parallel` 且额度未满 ⇒ 真的起第二路(两条不同的 turnId、各自的路标识);
|
||
// ③ 额度满 ⇒ 落回排队(不报错、不丢消息),并如实给出 reason;
|
||
// ④ 单路时 `session_idle` 的时点不变;
|
||
// ⑤ 并行时 `session_idle` 等到**所有路**收尾;
|
||
// ⑥ turnId 一致性(编排器生成的那个 = 交给 Run、并由流帧盖上壳的那个)。
|
||
//
|
||
// 夹具刻意用**可逐路放行**的 runner(放行顺序即收尾顺序),因此「谁先收尾」是
|
||
// 确定性的,断言的是时序而不是睡眠。
|
||
|
||
// gatedLegRunner 是可控的 TurnRunner:每条路的 Run 都阻塞在自己的放行闸上,
|
||
// 测试按启动顺序逐路放行,因此能精确断言「一路收尾而另一路仍在飞」的那一刻。
|
||
type gatedLegRunner struct {
|
||
mu sync.Mutex
|
||
calls []callRecord
|
||
gates []chan struct{}
|
||
started chan struct{}
|
||
}
|
||
|
||
func newGatedLegRunner() *gatedLegRunner {
|
||
return &gatedLegRunner{started: make(chan struct{}, 8)}
|
||
}
|
||
|
||
func (g *gatedLegRunner) Run(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv) (*loop.Result, error) {
|
||
gate := make(chan struct{})
|
||
g.mu.Lock()
|
||
idx := len(g.calls)
|
||
g.calls = append(g.calls, callRecord{sessionID: sessionID, messages: append([]model.Message(nil), messages...), env: env, ctx: ctx})
|
||
g.gates = append(g.gates, gate)
|
||
g.mu.Unlock()
|
||
g.started <- struct{}{}
|
||
select {
|
||
case <-gate:
|
||
case <-ctx.Done():
|
||
return nil, ctx.Err()
|
||
}
|
||
return &loop.Result{Text: fmt.Sprintf("reply-%d", idx+1), Steps: 1, StopReason: "stop"}, nil
|
||
}
|
||
|
||
// waitStarted 等到至少 n 条路真的进了 runner(而不是只被登记)。
|
||
func (g *gatedLegRunner) waitStarted(n int) []callRecord {
|
||
deadline := time.Now().Add(3 * time.Second)
|
||
for time.Now().Before(deadline) {
|
||
g.mu.Lock()
|
||
got := len(g.calls)
|
||
out := append([]callRecord(nil), g.calls...)
|
||
g.mu.Unlock()
|
||
if got >= n {
|
||
return out
|
||
}
|
||
time.Sleep(time.Millisecond)
|
||
}
|
||
panic("timed out waiting for leg starts")
|
||
}
|
||
|
||
// release 放行第 i 条启动的路(i 从 0 起)。它幂等(重复关闭会 panic,
|
||
// 因此用 select 保护),使测试的放行顺序就是收尾顺序。
|
||
func (g *gatedLegRunner) release(i int) {
|
||
g.mu.Lock()
|
||
gate := g.gates[i]
|
||
g.mu.Unlock()
|
||
select {
|
||
case <-gate:
|
||
default:
|
||
close(gate)
|
||
}
|
||
}
|
||
|
||
// turnStateRec 是一次状态帧(含帧壳上的 turnId,可能为空)。
|
||
type turnStateRec struct {
|
||
sessionID string
|
||
turnID string
|
||
typ string
|
||
data map[string]any
|
||
}
|
||
|
||
// turnSink 同时实现 StateSink 与 TurnStateSink,把两类帧记在同一个序列里,
|
||
// 使「带 turnId 的变体真的把 turnId 盖到帧壳上」可被直接断言。
|
||
type turnSink struct {
|
||
mu sync.Mutex
|
||
events []turnStateRec
|
||
}
|
||
|
||
func (s *turnSink) Emit(sessionID, typ string, data map[string]any) {
|
||
s.record(turnStateRec{sessionID: sessionID, typ: typ, data: data})
|
||
}
|
||
|
||
func (s *turnSink) EmitTurn(sessionID, turnID, typ string, data map[string]any) {
|
||
s.record(turnStateRec{sessionID: sessionID, turnID: turnID, typ: typ, data: data})
|
||
}
|
||
|
||
func (s *turnSink) record(rec turnStateRec) {
|
||
s.mu.Lock()
|
||
s.events = append(s.events, rec)
|
||
s.mu.Unlock()
|
||
}
|
||
|
||
func (s *turnSink) snapshot() []turnStateRec {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
return append([]turnStateRec(nil), s.events...)
|
||
}
|
||
|
||
// ofType 返回某类状态帧(按发生顺序)。
|
||
func (s *turnSink) ofType(typ string) []turnStateRec {
|
||
var out []turnStateRec
|
||
for _, e := range s.snapshot() {
|
||
if e.typ == typ {
|
||
out = append(out, e)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// parallelFixture 是并行管线用例的公共夹具。
|
||
type parallelFixture struct {
|
||
o *Orchestrator
|
||
run *gatedLegRunner
|
||
sink *turnSink
|
||
history *fakeHistory
|
||
}
|
||
|
||
// newParallelFixture 按给定的队列容量与并行上限构建编排器(Log 走丢弃型,
|
||
// 避免并行用例互相污染日志断言)。
|
||
func newParallelFixture(maxPending, maxParallel int) *parallelFixture {
|
||
run := newGatedLegRunner()
|
||
sink := &turnSink{}
|
||
hist := &fakeHistory{}
|
||
o := NewOrchestrator(OrchestratorOptions{
|
||
Run: run.Run,
|
||
Emit: sink.Emit,
|
||
EmitTurn: sink.EmitTurn,
|
||
Log: slog.New(slog.NewTextHandler(discardWriter{}, nil)),
|
||
MaxPending: maxPending,
|
||
MaxTail: 20,
|
||
History: hist,
|
||
MaxParallelTurns: maxParallel,
|
||
})
|
||
return ¶llelFixture{o: o, run: run, sink: sink, history: hist}
|
||
}
|
||
|
||
// discardWriter 接收被丢弃的日志字节(用例只关心状态帧与历史行)。
|
||
type discardWriter struct{}
|
||
|
||
func (discardWriter) Write(p []byte) (int, error) { return len(p), nil }
|
||
|
||
// waitActiveTurns 等到某会话的在途路数等于 want(超时即失败),使断言不靠睡眠。
|
||
func waitActiveTurns(t *testing.T, o *Orchestrator, sessionID string, want int) {
|
||
t.Helper()
|
||
deadline := time.Now().Add(3 * time.Second)
|
||
for time.Now().Before(deadline) {
|
||
if o.ActiveTurns(sessionID) == want {
|
||
return
|
||
}
|
||
time.Sleep(time.Millisecond)
|
||
}
|
||
t.Fatalf("在途路数 = %d, want %d", o.ActiveTurns(sessionID), want)
|
||
}
|
||
|
||
// stateInt 从状态帧的 data 里取一个整数键(缺失或类型不符时返回 -1)。
|
||
func stateInt(rec turnStateRec, key string) int {
|
||
v, ok := rec.data[key].(int)
|
||
if !ok {
|
||
return -1
|
||
}
|
||
return v
|
||
}
|
||
|
||
// stateKeyPresent 报告状态帧的 data 里某个键是否**在场**(用于「该键缺席」的断言:
|
||
// 缺席与空串对客户端含义不同,绝不伪造)。
|
||
func stateKeyPresent(rec turnStateRec, key string) bool {
|
||
_, ok := rec.data[key]
|
||
return ok
|
||
}
|
||
|
||
// assistantContents 返回已落库记录里的助手回复正文(按顺序)。
|
||
func assistantContents(msgs []history.Message) []string {
|
||
out := make([]string, 0, len(msgs))
|
||
for _, m := range msgs {
|
||
if m.Role == history.RoleAssistant {
|
||
out = append(out, m.Content)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// windowText 返回一路播种到的模型消息正文(按顺序),供断言 I4「每路一份窗口」。
|
||
func windowText(msgs []model.Message) []string {
|
||
out := make([]string, 0, len(msgs))
|
||
for _, m := range msgs {
|
||
out = append(out, messageText(m))
|
||
}
|
||
return out
|
||
}
|
||
|
||
// parallelCtx 返回一条**显式声明并行**的提交上下文(与 WS/REST 边界同源的 ctx 通道)。
|
||
func parallelCtx() context.Context { return withParallelTurn(context.Background(), true) }
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// ① 省略 parallel ⇒ 逐字不变
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// TestParallelOmittedKeepsQueueingBehaviour 钉住「老客户端零影响」:不声明 parallel
|
||
// 的忙时消息仍然入队,帧形状不变(**没有** reason 键),且始终只有一条在途路。
|
||
func TestParallelOmittedKeepsQueueingBehaviour(t *testing.T) {
|
||
t.Parallel()
|
||
f := newParallelFixture(5, 2)
|
||
|
||
if sr := f.o.Submit(context.Background(), "web", []model.Message{userMsg("第一问")}, loop.OwnerEnv()); !sr.Immediate || sr.Parallel {
|
||
t.Fatalf("空闲提交 = %+v, want Immediate 且非 Parallel", sr)
|
||
}
|
||
f.run.waitStarted(1)
|
||
if got := f.o.ActiveTurns("web"); got != 1 {
|
||
t.Fatalf("在途路数 = %d, want 1", got)
|
||
}
|
||
|
||
// 忙时第二条(未声明并行)⇒ 排队,且帧上没有任何新键。
|
||
sr := f.o.Submit(context.Background(), "web", []model.Message{userMsg("第二问")}, loop.OwnerEnv())
|
||
if !sr.Pending || sr.Parallel || sr.QueueDepth != 1 {
|
||
t.Fatalf("忙时未声明并行的提交 = %+v, want Pending(1) 且非 Parallel", sr)
|
||
}
|
||
if got := f.o.ActiveTurns("web"); got != 1 {
|
||
t.Fatalf("排队时在途路数 = %d, want 1(绝不新开路)", got)
|
||
}
|
||
acks := f.sink.ofType(EventPendingAck)
|
||
if len(acks) != 1 {
|
||
t.Fatalf("pending_ack 帧数 = %d, want 1", len(acks))
|
||
}
|
||
if stateKeyPresent(acks[0], "reason") {
|
||
t.Fatalf("未声明并行的排队不该带 reason,得到 %v", acks[0].data)
|
||
}
|
||
if stateInt(acks[0], "queueDepth") != 1 {
|
||
t.Fatalf("pending_ack queueDepth = %v, want 1", acks[0].data["queueDepth"])
|
||
}
|
||
if stateInt(acks[0], "activeTurns") != -1 {
|
||
t.Fatalf("pending_ack 不该带 activeTurns(契约里只有 session_busy/idle/drain/interrupted 带它):%v", acks[0].data)
|
||
}
|
||
|
||
// 放行第一路 ⇒ 弹队首续跑(仍是串行,一条在途路)。
|
||
f.run.release(0)
|
||
calls := f.run.waitStarted(2)
|
||
if got := f.o.ActiveTurns("web"); got != 1 {
|
||
t.Fatalf("续跑时在途路数 = %d, want 1", got)
|
||
}
|
||
if got := loop.ParallelLegFrom(calls[1].ctx); got != "" {
|
||
t.Fatalf("续跑路的路标识 = %q, want 空串(串行路不挂并发路标识)", got)
|
||
}
|
||
if loop.TurnIDFrom(calls[1].ctx) == loop.TurnIDFrom(calls[0].ctx) {
|
||
t.Fatal("续跑路必须是一条**新路**,turnId 不能与上一路相同")
|
||
}
|
||
// 工具互斥身份是**另一条通道**(design/15 §2.5.7):续跑路同样必须带上它,
|
||
// 取值就是它自己的 turnId —— 否则串行续跑路在写类工具上不受保护。它与
|
||
// checkpoint 归属键无关,所以上面那条「空串」断言不该被它推翻。
|
||
if got := loop.ToolLegIDFrom(calls[1].ctx); got != loop.TurnIDFrom(calls[1].ctx) {
|
||
t.Fatalf("续跑路的互斥身份 = %q, want %q(每一路都要盖)", got, loop.TurnIDFrom(calls[1].ctx))
|
||
}
|
||
if loop.ToolLegIDFrom(calls[1].ctx) == loop.ToolLegIDFrom(calls[0].ctx) {
|
||
t.Fatal("两条路的互斥身份必须不同,否则同文件写会被当成「同一路自己的调用」而放行")
|
||
}
|
||
drains := f.sink.ofType(EventPendingDrain)
|
||
if len(drains) != 1 || stateInt(drains[0], "activeTurns") != 1 {
|
||
t.Fatalf("pending_drain = %v, want 一帧且 activeTurns=1", drains)
|
||
}
|
||
|
||
f.run.release(1)
|
||
if !waitIdle(f.o, "web") {
|
||
t.Fatal("会话没有回到空闲")
|
||
}
|
||
idle := f.sink.ofType(EventSessionIdle)
|
||
if len(idle) != 1 || stateInt(idle[0], "activeTurns") != 0 {
|
||
t.Fatalf("session_idle = %v, want 一帧且 activeTurns=0", idle)
|
||
}
|
||
busy := f.sink.ofType(EventSessionBusy)
|
||
if len(busy) != 1 || stateInt(busy[0], "activeTurns") != 1 {
|
||
t.Fatalf("session_busy = %v, want 一帧且 activeTurns=1", busy)
|
||
}
|
||
if got := len(assistantContents(f.history.records())); got != 2 {
|
||
t.Fatalf("助手回复行数 = %d, want 2(每条消息各答一次)", got)
|
||
}
|
||
}
|
||
|
||
// TestSessionIdleTimingSingleLegUnchanged 钉住④:单路(含排队续跑)的
|
||
// `session_idle` 时点与现状一致 —— 队列空且本路收尾就报,绝不多等、绝不少报。
|
||
func TestSessionIdleTimingSingleLegUnchanged(t *testing.T) {
|
||
t.Parallel()
|
||
f := newParallelFixture(5, 2)
|
||
|
||
if sr := f.o.Submit(context.Background(), "web", []model.Message{userMsg("甲")}, loop.OwnerEnv()); !sr.Immediate {
|
||
t.Fatalf("提交 = %+v, want Immediate", sr)
|
||
}
|
||
f.run.waitStarted(1)
|
||
if got := f.sink.ofType(EventSessionIdle); len(got) != 0 {
|
||
t.Fatalf("回合在飞时不该报空闲,得到 %v", got)
|
||
}
|
||
if got := f.o.ActiveTurns("web"); got != 1 {
|
||
t.Fatalf("在途路数 = %d, want 1", got)
|
||
}
|
||
f.run.release(0)
|
||
if !waitIdle(f.o, "web") {
|
||
t.Fatal("会话没有回到空闲")
|
||
}
|
||
if got := f.sink.ofType(EventSessionIdle); len(got) != 1 {
|
||
t.Fatalf("session_idle 帧数 = %d, want 1", len(got))
|
||
}
|
||
if got := f.o.ActiveTurns("web"); got != 0 {
|
||
t.Fatalf("收尾后在途路数 = %d, want 0", got)
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// ② 显式 parallel 且额度未满 ⇒ 真的起第二路
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// TestParallelDeclaredStartsSecondLeg 钉住②与⑥:第二条显式声明并行的消息**立即**
|
||
// 起一条独立的路(两路 turnId 不同、并行路带自己的路标识),两路同时生长,各自
|
||
// 落一行助手回复,且 `session_idle` 只在两路都收尾后出现一次。
|
||
func TestParallelDeclaredStartsSecondLeg(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.Immediate || !second.Parallel {
|
||
t.Fatalf("显式并行提交 = %+v, want Immediate 且 Parallel", second)
|
||
}
|
||
if second.TurnID == "" || second.TurnID == first.TurnID {
|
||
t.Fatalf("第二路 turnId = %q,第一路 = %q:必须各自不同", second.TurnID, first.TurnID)
|
||
}
|
||
if f.o.QueueDepth("web") != 0 {
|
||
t.Fatalf("并行路不该入队,队列深度 = %d", f.o.QueueDepth("web"))
|
||
}
|
||
calls := f.run.waitStarted(2)
|
||
waitActiveTurns(t, f.o, "web", 2)
|
||
|
||
// I4:每一路都有**自己的一份窗口** —— 各自带来自己那条用户消息,且看不到
|
||
// 对方在跑的东西(对方的答复尚未落库,因此不在任何一路的转录里)。
|
||
if got := windowText(calls[0].messages); len(got) != 1 || got[0] != "第一问" {
|
||
t.Fatalf("第一路的窗口 = %v, want [第一问]", got)
|
||
}
|
||
if got := windowText(calls[1].messages); len(got) != 1 || got[0] != "第二问" {
|
||
t.Fatalf("第二路的窗口 = %v, want [第二问]", got)
|
||
}
|
||
|
||
// ⑥ 两路各自的 turnId:第一路不带并发路标识(checkpoint 归属键仍是 sessionID),
|
||
// 第二路带自己的(归属键升为 {sessionID}#{turnID}),且都与提交时交回的 turnId 同值。
|
||
if got := loop.TurnIDFrom(calls[0].ctx); got != first.TurnID {
|
||
t.Fatalf("第一路 ctx turnId = %q,提交回报 %q:两处必须同值", got, first.TurnID)
|
||
}
|
||
if got := loop.ParallelLegFrom(calls[0].ctx); got != "" {
|
||
t.Fatalf("第一路不该挂并发路标识,得到 %q", got)
|
||
}
|
||
if got := loop.TurnIDFrom(calls[1].ctx); got != second.TurnID {
|
||
t.Fatalf("第二路 ctx turnId = %q,提交回报 %q:两处必须同值", got, second.TurnID)
|
||
}
|
||
if got := loop.ParallelLegFrom(calls[1].ctx); got != second.TurnID {
|
||
t.Fatalf("第二路路标识 = %q, want %q(checkpoint 归属键按路隔离)", got, second.TurnID)
|
||
}
|
||
|
||
// 忙态帧如实报告两路在途。
|
||
busy := f.sink.ofType(EventSessionBusy)
|
||
if len(busy) != 2 || stateInt(busy[1], "activeTurns") != 2 {
|
||
t.Fatalf("session_busy 帧 = %v, want 两帧且第二帧 activeTurns=2", busy)
|
||
}
|
||
|
||
// ⑤ 先收尾第二路:会话仍不空闲(第一路还在飞)。
|
||
f.run.release(1)
|
||
waitActiveTurns(t, f.o, "web", 1)
|
||
if got := f.sink.ofType(EventSessionIdle); len(got) != 0 {
|
||
t.Fatalf("还有一路在飞时不该报空闲,得到 %v", got)
|
||
}
|
||
|
||
f.run.release(0)
|
||
if !waitIdle(f.o, "web") {
|
||
t.Fatal("所有路收尾后会话没有回到空闲")
|
||
}
|
||
idle := f.sink.ofType(EventSessionIdle)
|
||
if len(idle) != 1 || stateInt(idle[0], "activeTurns") != 0 {
|
||
t.Fatalf("session_idle = %v, want 恰好一帧且 activeTurns=0", idle)
|
||
}
|
||
|
||
// 两路各落一行助手回复(I5:绝不合并成一行、不覆盖对方),且每条用户消息只被
|
||
// 作答一次(两条 user 行、两条 assistant 行)。
|
||
rows := f.history.records()
|
||
if got := len(assistantContents(rows)); got != 2 {
|
||
t.Fatalf("助手回复行数 = %d, want 2", got)
|
||
}
|
||
users := 0
|
||
for _, m := range rows {
|
||
if m.Role == history.RoleUser {
|
||
users++
|
||
}
|
||
}
|
||
if users != 2 {
|
||
t.Fatalf("用户消息行数 = %d, want 2(恰好一次)", users)
|
||
}
|
||
}
|
||
|
||
// TestSessionIdleWaitsForAllLegs 是⑤的**最小时序**回归:并行两路,任意顺序收尾,
|
||
// 只有第二路也收尾时才出现唯一的 session_idle。
|
||
func TestSessionIdleWaitsForAllLegs(t *testing.T) {
|
||
t.Parallel()
|
||
f := newParallelFixture(5, 2)
|
||
f.o.Submit(context.Background(), "web", []model.Message{userMsg("A")}, loop.OwnerEnv())
|
||
f.run.waitStarted(1)
|
||
f.o.Submit(parallelCtx(), "web", []model.Message{userMsg("B")}, loop.OwnerEnv())
|
||
f.run.waitStarted(2)
|
||
waitActiveTurns(t, f.o, "web", 2)
|
||
|
||
// 先收尾**第一**路(与上一个用例相反的收尾顺序,证明判据不是「最后启动的那一路」)。
|
||
f.run.release(0)
|
||
waitActiveTurns(t, f.o, "web", 1)
|
||
if got := f.sink.ofType(EventSessionIdle); len(got) != 0 {
|
||
t.Fatalf("一路收尾、另一路仍在飞 ⇒ 不该报空闲,得到 %v", got)
|
||
}
|
||
f.run.release(1)
|
||
if !waitIdle(f.o, "web") {
|
||
t.Fatal("所有路收尾后会话没有回到空闲")
|
||
}
|
||
if got := f.sink.ofType(EventSessionIdle); len(got) != 1 {
|
||
t.Fatalf("session_idle 帧数 = %d, want 1", len(got))
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// ③ 额度满 ⇒ 落回排队
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// TestParallelQuotaFullFallsBackToQueue 钉住③:额度(默认 2)用满后,再来的显式
|
||
// 并行消息**落回既有排队语义** —— 不报错、不打断、不丢,并如实给出
|
||
// `pending_ack{reason:"parallel_limit"}`;被排队的消息最终仍被作答一次。
|
||
func TestParallelQuotaFullFallsBackToQueue(t *testing.T) {
|
||
t.Parallel()
|
||
f := newParallelFixture(5, 2)
|
||
f.o.Submit(context.Background(), "web", []model.Message{userMsg("第一问")}, loop.OwnerEnv())
|
||
f.run.waitStarted(1)
|
||
if sr := f.o.Submit(parallelCtx(), "web", []model.Message{userMsg("第二问")}, loop.OwnerEnv()); !sr.Parallel {
|
||
t.Fatalf("额度未满时应当照准并行,得到 %+v", sr)
|
||
}
|
||
f.run.waitStarted(2)
|
||
waitActiveTurns(t, f.o, "web", 2)
|
||
|
||
// 第三条声明并行的消息:额度已满 ⇒ 排队。
|
||
third := f.o.Submit(parallelCtx(), "web", []model.Message{userMsg("第三问")}, loop.OwnerEnv())
|
||
if !third.Accepted || !third.Pending || third.Parallel {
|
||
t.Fatalf("额度满的并行提交 = %+v, want Accepted+Pending 且非 Parallel", third)
|
||
}
|
||
if third.QueueDepth != 1 {
|
||
t.Fatalf("队列深度 = %d, want 1", third.QueueDepth)
|
||
}
|
||
acks := f.sink.ofType(EventPendingAck)
|
||
if len(acks) != 1 || acks[0].data["reason"] != pendingAckReasonParallelLimit {
|
||
t.Fatalf("pending_ack = %v, want reason=%q", acks, pendingAckReasonParallelLimit)
|
||
}
|
||
if got := f.o.ActiveTurns("web"); got != 2 {
|
||
t.Fatalf("落回排队不该改变在途路数,得到 %d", got)
|
||
}
|
||
|
||
// 逐路收尾:排队的那条由结算中的一路续跑(起一条**串行**新路),最终被作答。
|
||
f.run.release(0)
|
||
f.run.waitStarted(3)
|
||
f.run.release(1)
|
||
f.run.release(2)
|
||
if !waitIdle(f.o, "web") {
|
||
t.Fatal("会话没有回到空闲")
|
||
}
|
||
if got := len(assistantContents(f.history.records())); got != 3 {
|
||
t.Fatalf("助手回复行数 = %d, want 3(排队的第三条也必须被作答一次)", got)
|
||
}
|
||
}
|
||
|
||
// TestParallelDisabledByLimitBelowTwo 钉住「<2 = 关闭真并行 ⇒ 完全回落到现状」:
|
||
// 上限为 1 时显式并行也一律排队,在途路数恒不越过 1。
|
||
func TestParallelDisabledByLimitBelowTwo(t *testing.T) {
|
||
t.Parallel()
|
||
f := newParallelFixture(5, 1)
|
||
f.o.Submit(context.Background(), "web", []model.Message{userMsg("第一问")}, loop.OwnerEnv())
|
||
f.run.waitStarted(1)
|
||
|
||
sr := f.o.Submit(parallelCtx(), "web", []model.Message{userMsg("第二问")}, loop.OwnerEnv())
|
||
if !sr.Pending || sr.Parallel {
|
||
t.Fatalf("真并行被关闭时 = %+v, want Pending 且非 Parallel", sr)
|
||
}
|
||
if got := f.o.ActiveTurns("web"); got != 1 {
|
||
t.Fatalf("在途路数 = %d, want 1(关闭真并行)", got)
|
||
}
|
||
acks := f.sink.ofType(EventPendingAck)
|
||
if len(acks) != 1 || acks[0].data["reason"] != pendingAckReasonParallelLimit {
|
||
t.Fatalf("pending_ack = %v, want 仍如实给出 reason", acks)
|
||
}
|
||
f.run.release(0)
|
||
f.run.waitStarted(2)
|
||
f.run.release(1)
|
||
if !waitIdle(f.o, "web") {
|
||
t.Fatal("会话没有回到空闲")
|
||
}
|
||
}
|
||
|
||
// TestResolveMaxParallelTurns 钉住额度解析的三档边界(默认 / 关闭 / 硬上限)。
|
||
func TestResolveMaxParallelTurns(t *testing.T) {
|
||
t.Parallel()
|
||
cases := []struct {
|
||
in int
|
||
want int
|
||
}{
|
||
{-5, defaultMaxParallelTurns},
|
||
{0, defaultMaxParallelTurns},
|
||
{1, 1},
|
||
{2, 2},
|
||
{3, 3},
|
||
{99, maxParallelTurnsHardCap},
|
||
}
|
||
for _, c := range cases {
|
||
if got := resolveMaxParallelTurns(c.in); got != c.want {
|
||
t.Fatalf("resolveMaxParallelTurns(%d) = %d, want %d", c.in, got, c.want)
|
||
}
|
||
}
|
||
}
|