feat(gateway,loop): 忙时并行回合基础管线(I1′ 有条件放宽)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- turnId 由编排器生成、loop Run 复用(硬前提:否则 turn_begin 与流帧各铸一个,客户端两级分桶立即失配)
- StateSink 增带 turnId 的变体(未接线时退化为现签名,不丢状态帧)
- 上行 chat.parallel(WS chat 帧 + REST 三入口同形;省略 = 逐字不变)
- 单会话额度默认 2 / 硬上限 3 / <2 关闭;额度满或未声明 ⇒ 落回既有排队语义
- I1′:checkpoint 归属、历史配对、忙态计数三处从会话级单例升为路级
- session_idle 收紧为「所有在途路收尾且 pending 空」(仅并行时生效;单路逐字不变)
- 预留接口(形状定死、无实现无接线):tool/legfile.go 的 FileClaimer / ErrFileBusy / WithLegID
- 顺手修一个真 bug:并行路 ctx 派生自提交请求作用域,REST 同步入口一写响应就把它连坐杀死(改 context.WithoutCancel 派生)
- 23 个新用例(含 -race -count=3);变异 6 条(turnId 复用 / idle 时点 / 并行升级 / checkpoint 键 / 编排器交 id / pending_ack reason)
- 契约变更点 10 条待监工升 v0.65(本人未改 Docs/**)
This commit is contained in:
AskaEth committed 2026-10-05 09:18:50 +08:00
1 parent 7d2ff74cfc
commit 89e13cf53d
23 files changed
+1921 -275

No files matched your search

@@ -0,0 +1,531 @@
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 &parallelFixture{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 不能与上一路相同")
}
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)
}
}
}