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,233 @@
package gateway
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件是 design/15 §2.5 基础管线的**协议面**回归:turnId 一致性
// (编排器生成 → loop 复用 → 流帧帧壳同值)、带 turnId 的状态通道变体、
// 上行 `parallel` 的解码,以及 REST 三条入口的应答形状。夹具留在
// session_parallel_test.go(同包),两半共用。
// ---------------------------------------------------------------------------
// ⑥ turnId 一致性(编排器生成 → loop 复用 → 流帧帧壳同值)
// ---------------------------------------------------------------------------
// TestRunTurnReusesOrchestratorTurnID 钉住①的硬前提:hub 的 runTurn 必须**复用**
// 编排器已经生成并交给 Run 的那个 turnId(否则 `turn_begin` 与流帧各生成一个,
// 客户端两级分桶立即失配);未挂载时才新铸一个,因此单路路径无可见差异。
func TestRunTurnReusesOrchestratorTurnID(t *testing.T) {
t.Parallel()
h := newTestHub(&stubRunner{res: &loop.Result{Text: "ok", StopReason: "stop"}}, nil)
conn := newOwnerConn(h, 16)
h.register(conn)
const want = "turn-orchestrator-made"
ctx := loop.WithTurnID(context.Background(), want)
if _, err := h.runTurn(ctx, "web", []model.Message{userMsg("你好")}, loop.OwnerEnv()); err != nil {
t.Fatalf("runTurn: %v", err)
}
// 一回合发两帧(stream_chunk + stream_end),两帧的帧壳都必须带上同一个 id。
for _, typ := range []string{"stream_chunk", "stream_end"} {
if ev := waitFrame(t, conn, typ); ev.TurnID != want {
t.Fatalf("%s 帧壳 turnId = %q, want %q(编排器生成的那个)", typ, ev.TurnID, want)
}
}
// 未挂载 ⇒ 每回合一个全新的随机 id(老契约不变)。
if _, err := h.runTurn(context.Background(), "web", []model.Message{userMsg("再来")}, loop.OwnerEnv()); err != nil {
t.Fatalf("runTurn: %v", err)
}
waitFrame(t, conn, "stream_chunk")
ev := waitFrame(t, conn, "stream_end")
if ev.TurnID == "" || ev.TurnID == want {
t.Fatalf("未挂载时应新铸一个 turnId,得到 %q", ev.TurnID)
}
}
// TestEmitTurnVariantCarriesTurnID 钉住②:带 turnId 的状态通道变体把 turnId 盖到
// 帧壳上;未接线该变体时退化为普通状态帧(绝不因此丢掉一条状态迁移)。
func TestEmitTurnVariantCarriesTurnID(t *testing.T) {
t.Parallel()
sink := &turnSink{}
o := NewOrchestrator(OrchestratorOptions{Run: fixedRunner(nil), Emit: sink.Emit, EmitTurn: sink.EmitTurn})
o.emitTurnState("web", "turn-1", EventSessionBusy, busyPayload("web", ModeThinking, 2))
got := sink.snapshot()
if len(got) != 1 || got[0].turnID != "turn-1" || got[0].typ != EventSessionBusy {
t.Fatalf("带 turnId 的变体 = %+v, want turnId=turn-1 的 session_busy", got)
}
if stateInt(got[0], "activeTurns") != 2 {
t.Fatalf("activeTurns = %v, want 2", got[0].data["activeTurns"])
}
plain := &turnSink{}
o2 := NewOrchestrator(OrchestratorOptions{Run: fixedRunner(nil), Emit: plain.Emit})
o2.emitTurnState("web", "turn-1", EventSessionBusy, busyPayload("web", ModeThinking, 1))
got = plain.snapshot()
if len(got) != 1 || got[0].turnID != "" {
t.Fatalf("未接线变体时应退化为普通帧(无 turnId),得到 %+v", got)
}
}
// TestChatStreamRequestDecodesParallel 钉住 WS 上行帧的解码:`parallel:true` 被
// 读懂,省略时为零值(因此省略该字段的老客户端行为逐字不变)。
func TestChatStreamRequestDecodesParallel(t *testing.T) {
t.Parallel()
var on chatStreamRequest
if err := json.Unmarshal([]byte(`{"sessionId":"web","text":"hi","parallel":true}`), &on); err != nil {
t.Fatalf("解码: %v", err)
}
if !on.Parallel {
t.Fatal("parallel:true 没有被解码到")
}
var off chatStreamRequest
if err := json.Unmarshal([]byte(`{"sessionId":"web","text":"hi"}`), &off); err != nil {
t.Fatalf("解码: %v", err)
}
if off.Parallel {
t.Fatal("省略 parallel 时必须为零值")
}
}
// TestRESTParallelAckShapes 钉住 REST 两条入口的应答形状:stream 路径在照准并行时
// 明确回报 parallel+turnId(而不是一个什么都没说的 200),同步路径回 202(第二路在
// 后台跑,拿不到同步回复)。
func TestRESTParallelAckShapes(t *testing.T) {
t.Parallel()
s := &Server{}
rec := httptest.NewRecorder()
s.writeChatStreamAck(rec, SubmitResult{Accepted: true, Immediate: true, Parallel: true, TurnID: "turn-9"}, "web")
if rec.Code != http.StatusOK {
t.Fatalf("stream 并行 ack 状态码 = %d, want 200", rec.Code)
}
var body struct {
Data map[string]any `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("解码 ack: %v", err)
}
if body.Data["stream"] != true || body.Data["parallel"] != true || body.Data["turnId"] != "turn-9" {
t.Fatalf("stream 并行 ack = %v, want {stream:true, parallel:true, turnId:turn-9}", body.Data)
}
// 现状不动:排队仍是 202 + queued。
rec = httptest.NewRecorder()
s.writeChatStreamAck(rec, SubmitResult{Accepted: true, Pending: true, QueueDepth: 3}, "web")
if rec.Code != http.StatusAccepted {
t.Fatalf("排队 ack 状态码 = %d, want 202", rec.Code)
}
}
// blockingChatter 是**永不返回**的旧式 Chatter(`Deps.Loop`):它把会话钉在
// 「忙」上,使黑盒用例能在真实 HTTP 层断言并行分支的应答形状 —— 而不必让第一条
// 请求同步跑完。它尊重 ctx:网关关闭 / 中断会让它立刻返回,不留泄漏的 goroutine。
type blockingChatter struct {
started chan struct{}
release chan struct{}
stopped sync.Once
}
func newBlockingChatter() *blockingChatter {
return &blockingChatter{started: make(chan struct{}, 8), release: make(chan struct{})}
}
func (b *blockingChatter) Run(ctx context.Context, _ string, _ []model.Message) (*loop.Result, error) {
b.started <- struct{}{}
select {
case <-b.release:
return &loop.Result{Text: "ok", StopReason: "stop"}, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
func (b *blockingChatter) stop() { b.stopped.Do(func() { close(b.release) }) }
// TestRESTParallelEntriesShareOneShape 是 design/15 §2.5.2 的黑盒回归:REST 的
// 三条入口**同形** ——
// - 第一条经 `stream:true` 进入(提交即返回,回合在后台被钉住,会话因此忙碌);
// - 第二条走默认(同步)入口 + `parallel:true` ⇒ 202 + `parallel:true` + turnId
// (第二路在后台跑,同步入口拿不到回复,绝不回一个 res 为 nil 的 200);
// - 第三条省掉 `parallel` ⇒ 仍是既有的 202 `{queued:true, queueDepth}`。
//
// 它同时覆盖 Deps.MaxParallelTurns → 编排器的接线(第二路真的起来了,用 turnId
// 佐证:只有真的开了一路才会有它)。
func TestRESTParallelEntriesShareOneShape(t *testing.T) {
t.Parallel()
ch := newBlockingChatter()
srv, ts, jwt := buildEnv(t, nil, Deps{Loop: ch, MaxParallelTurns: 2})
t.Cleanup(func() { ch.stop(); srv.Close() })
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/chat", map[string]any{
"sessionId": "web", "text": "第一问", "stream": true,
}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("stream 入口状态码 = %d, want 200", resp.StatusCode)
}
select {
case <-ch.started:
case <-time.After(3 * time.Second):
t.Fatal("后台回合没有开跑(会话应当是忙碌的)")
}
if !srv.hub.orchestrator.Busy("web") {
t.Fatal("会话没有进入忙碌态,后面的并行断言不成立")
}
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/chat", map[string]any{
"sessionId": "web", "text": "第二问", "parallel": true,
}, tok)
if resp.StatusCode != http.StatusAccepted {
t.Fatalf("同步入口 + parallel 状态码 = %d, want 202(第二路在后台跑)", resp.StatusCode)
}
var accepted map[string]any
if err := json.Unmarshal(decodeBody(t, body)["data"], &accepted); err != nil {
t.Fatalf("解码并行 ack: %v (body=%s)", err, body)
}
if accepted["parallel"] != true {
t.Fatalf("同步入口并行 ack = %v, want parallel:true", accepted)
}
if turnID, _ := accepted["turnId"].(string); turnID == "" {
t.Fatalf("并行 ack 必须给出该路的 turnId,得到 %v", accepted)
}
waitActiveTurns(t, srv.hub.orchestrator, "web", 2)
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/chat", map[string]any{
"sessionId": "web", "text": "第三问",
}, tok)
if resp.StatusCode != http.StatusAccepted {
t.Fatalf("未声明并行的忙时提交状态码 = %d, want 202", resp.StatusCode)
}
var queued map[string]any
if err := json.Unmarshal(decodeBody(t, body)["data"], &queued); err != nil {
t.Fatalf("解码排队 ack: %v (body=%s)", err, body)
}
if queued["queued"] != true || queued["parallel"] != nil {
t.Fatalf("未声明并行的忙时 ack = %v, want {queued:true}(形状逐字不变)", queued)
}
}
// TestParallelTurnContextIsAdditive 钉住并行声明的 ctx 通道本身:false 是空操作
// (ctx 上不留痕迹),nil ctx 不 panic。
func TestParallelTurnContextIsAdditive(t *testing.T) {
t.Parallel()
if parallelRequested(withParallelTurn(context.Background(), false)) {
t.Fatal("false 必须是空操作:老客户端的 ctx 上不该留任何痕迹")
}
if !parallelRequested(withParallelTurn(context.Background(), true)) {
t.Fatal("true 必须可被读取侧看到")
}
if parallelRequested(nil) {
t.Fatal("nil ctx 不该被判为声明了并行")
}
}