Files
Cyrene_v2/internal/services/gateway/admin_flow_lanes_test.go
T
AskaEth 89434eadef
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(flow): graph_update 按路分桶——修真并行两路的图互相覆盖(FV2)
【取证(症状成立,且比登记描述更糟)】两路真并行时两路最终落盘**同一个 turn_id**,
其中一路的图彻底消失,且 A 路的工具被画在 B 路的图上。逐层定位:发布器 graphs 只按
sessionID 建键(后起的路整张顶掉前者)· 帧载荷基线的键集被既有测试精确断言为
{id,nodes,edges,sessionId,startedAt,endedAt}(无任何区分路的键)· 前端每会话单桶 ·
落盘 (session_id,turn_id) 唯一键两路相撞 · 冷启动 LastGraph 只有一张。
该 bug 早已公开登记(design/15 §2.5.10-1「本轮未解决」、契约 §6-19「仍**未修**」)。

【修法】
- 归属桶 {会话}[#{路}](loop.FlowOwnerKey,与 checkpoint 键同字符口径);路只来自
  ParallelLegFrom(ctx) ⇒ 单路逐字不变(桶 = 会话 id),既有调用点语义零改动
- 图 id 的第二分量改用**建图序号**(每次 +1、永不复用):同一轮里可以有并行路 +
  队首续跑的串行路,用轮次号会让两条串行路撞同一个 web-1
- 轮次号只在上一轮的路全部收尾后推进,并丢掉更早轮次的桶(否则内存随路数无界增长)
- 新增只读出口 LatestLanes ⇒ GET /admin/flow/graph 的 lanes[](最近一轮每一路),
  graph 仍是「最新一路」= 向后兼容
- 同源洞一并修:子代理归属原取 AgentSpec.ParentSession(会话、不含路)⇒ 并行路里派的
  子代理 span 被静默丢弃;新增 AgentSpec.FlowOwner 承载「会话#路」,ParentSession 继续
  只承载会话(总线载荷 subagent_result.sessionId 不受影响)
- 前端:store 增 flowLanes(按 turnId 分格、按轮淘汰、硬上限 4)+ LaneTracks 泳道卡;
  单路/未给 lanes 时渲染路径与改前逐字相同

【无迁移】turn_id 是自由文本、每路 id 唯一 ⇒ 0022 的唯一索引天然分两行(0023 仍空着)

【我的验收】① 隔离 worktree(30 文件)build/vet/linux/darwin 全过、69 包全绿 0 FAIL;
② 我亲手打红变异:让 FlowOwnerKey 忽略路 ⇒ TestFlowPublisher_ParallelLegsDoNotOverwriteEachOther
FAIL;③ 前端独立复跑 npx tsc --noEmit + npm run build 通过(4.12s)

⚠️ 契约 v0.77 的登记在紧随其后的 docs(contract) 提交里(同一个工作时段内)。
2026-10-06 08:16:03 +08:00

239 lines
9.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package gateway
// 本文件钉住契约 v0.77 的**按路**形状(切片 FV2,修 design/15 §2.5.10-1 登记的那条
// 「graph_update 互相顶掉」):
//
// - 帧 / 快照的图上多了 `turnId`(这张图属于哪一路的权威回合标识)与 `round`
// (会话轮次号),两者都是**纯增量**(老客户端忽略未知键仍可用);
// - `GET /api/v1/admin/flow/graph` 多了 `lanes[]`:真并行时同一会话同一轮有多条路,
// 只回 `graph` 一棵会把其余路从面板上抹掉。
import (
"encoding/json"
"net/http"
"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/trace"
)
// startLane 让发布器开始**一条路**的一轮(起轮 + 一个在飞的工具 span),并把归属桶与
// 该 span 交回调用方,供稍后收尾用。两条路必须**交错**起轮(B 在 A 在飞时起)——
// 那才是真并行的现场:轮次号正是在「上一轮的路全部收尾」之后才推进的。
func (e *flowTestEnv) startLane(t *testing.T, session, lane, input string) (string, *trace.Span) {
t.Helper()
owner := loop.FlowOwnerKey(session, lane)
e.flow.StartRound(owner, input)
sp := &trace.Span{ID: "span-" + lane, Name: "tool." + lane, Kind: trace.SpanKindTool, StartedAt: time.Now()}
e.flow.SpanStarted(owner, sp)
sp.Finish(trace.SpanStatusOK)
e.flow.SpanFinished(owner, sp)
return owner, sp
}
// finishLane 收尾一条路(答复播种 output 节点并发布最终帧)。
func (e *flowTestEnv) finishLane(t *testing.T, owner, output string) {
t.Helper()
e.flow.EndRound(owner, output)
}
// flowLanesData 解码响应体里的 `lanes[]`(契约 v0.77)。缺键时回 nil(调用方据此判红)。
func flowLanesData(t *testing.T, body []byte) []map[string]json.RawMessage {
t.Helper()
env := decodeBody(t, body)
var data struct {
Lanes []map[string]json.RawMessage `json:"lanes"`
}
if err := json.Unmarshal(env["data"], &data); err != nil {
t.Fatalf("decode lanes %s: %v", body, err)
}
return data.Lanes
}
// TestAdminFlowGraph_ReturnsEveryLaneOfLatestRound 是本次快照侧的核心断言:
// 真并行两路各自收轮之后打开页面,`lanes[]` 必须**每一路一条**、彼此不缺,
// 且 `graph`(向后兼容的那一棵)是其中最新的一路。
func TestAdminFlowGraph_ReturnsEveryLaneOfLatestRound(t *testing.T) {
env := newFlowTestEnv(t, func(string) bool { return true })
ownerA, _ := env.startLane(t, "web", "turn-a", "标记A")
ownerB, _ := env.startLane(t, "web", "turn-b", "标记B")
env.finishLane(t, ownerA, "答复A")
env.finishLane(t, ownerB, "答复B")
resp, body := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/flow/graph?sessionId=web", nil, env.token(t, auth.RoleOwner))
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
_, graph, present := flowGraphData(t, body)
if !present || graph == nil {
t.Fatalf("`graph` 必须恒在且非空(两路都收轮了):%s", body)
}
lanes := flowLanesData(t, body)
if len(lanes) != 2 {
t.Fatalf("lanes 条数 = %d,want 2(同一轮的两路各一条):%s", len(lanes), body)
}
seen := map[string]string{}
for _, lane := range lanes {
var (
turnID string
gid string
nodes []struct {
Kind string `json:"kind"`
Label string `json:"label"`
}
)
_ = json.Unmarshal(lane["turnId"], &turnID)
var inner map[string]json.RawMessage
if err := json.Unmarshal(lane["graph"], &inner); err != nil {
t.Fatalf("lanes[].graph 不是图对象:%v (%s)", err, body)
}
_ = json.Unmarshal(inner["id"], &gid)
if err := json.Unmarshal(inner["nodes"], &nodes); err != nil {
t.Fatalf("lanes[].graph.nodes: %v", err)
}
seen[turnID] = gid
for _, n := range nodes {
if n.Kind == "tool" && n.Label != turnID {
t.Fatalf("路 %s 的图里出现了另一路的工具节点 %q(两路又混进同一张图了)", turnID, n.Label)
}
}
}
for _, want := range []string{"turn-a", "turn-b"} {
if seen[want] == "" {
t.Fatalf("lanes[] 里缺 %s 那一路:%v (%s)", want, seen, body)
}
}
// graph(兼容字段)= 最新一路;两条路 id 必须不同(否则落盘会互相顶掉)。
if seen["turn-a"] == seen["turn-b"] {
t.Fatalf("两路拿到同一个图 id %q:%v", seen["turn-a"], seen)
}
var graphID string
_ = json.Unmarshal(graph["id"], &graphID)
if graphID != seen["turn-b"] {
t.Fatalf("graph.id = %q,want 最新一路 %q(后起的那一路最新)", graphID, seen["turn-b"])
}
}
// TestAdminFlowGraph_SingleLaneHasOneEntry 钉住单路口径:没有真并行时 `lanes[]` 是
// 恰好一条(无路标识),`graph` 与它同源 —— 「每一路」在单路下就是那一路,
// 不因为引入本键而多出一棵假的第二路。
func TestAdminFlowGraph_SingleLaneHasOneEntry(t *testing.T) {
env := newFlowTestEnv(t, func(string) bool { return true })
env.runFinishedRound(t, "web")
resp, body := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/flow/graph?sessionId=web", nil, env.token(t, auth.RoleOwner))
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
lanes := flowLanesData(t, body)
if len(lanes) != 1 {
t.Fatalf("单路的 lanes 条数 = %d,want 1:%s", len(lanes), body)
}
var turnID string
_ = json.Unmarshal(lanes[0]["turnId"], &turnID)
if turnID != "" {
t.Fatalf("单路的 lanes[0].turnId = %q,want 空(没有路标识 ⇒ 客户端按会话那一张画)", turnID)
}
}
// TestAdminFlowGraph_LanesEmptyOnEmptySession 钉住空态:该会话还没有图时 `lanes`
// 是空数组(恒非 null)—— 与 `graph:null` 同一诚实口径,客户端不必判空。
func TestAdminFlowGraph_LanesEmptyOnEmptySession(t *testing.T) {
env := newFlowTestEnv(t, func(string) bool { return true })
resp, body := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/flow/graph?sessionId=sess-backthink", nil, env.token(t, auth.RoleOwner))
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
lanes := flowLanesData(t, body)
if lanes == nil {
t.Fatalf("`lanes` 键必须恒在且是数组(空时是 []):%s", body)
}
if len(lanes) != 0 {
t.Fatalf("没有图时会话的 lanes = %s,want []", body)
}
}
// TestFlowGraphDispatchLaneFields 钉住**帧载荷**上的两个新键与它们的缺席语义:
// 有路时 `turnId` 在场且与图 id 同源,无路时该键**缺席**(老后端/单路的兼容口径),
// `round` 则每次建图都在(客户端据此只保留最新一轮的泳道)。
func TestFlowGraphDispatchLaneFields(t *testing.T) {
laneGraph := trace.NewRunGraph("web")
laneGraph.SetTurnID("turn-a")
laneGraph.SetSeq(3)
laneGraph.Start(1, time.Now())
laneGraph.SeedInput("标记A")
raw, err := json.Marshal(laneGraph.Snapshot())
if err != nil {
t.Fatalf("marshal: %v", err)
}
var got map[string]json.RawMessage
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("decode: %v", err)
}
var id, turnID string
var round int
_ = json.Unmarshal(got["id"], &id)
if _, ok := got["turnId"]; !ok {
t.Fatalf("有路的帧缺 `turnId` 键(客户端分不出两条泳道):%s", raw)
}
_ = json.Unmarshal(got["turnId"], &turnID)
_ = json.Unmarshal(got["round"], &round)
if id != "web-3#turn-a" || turnID != "turn-a" {
t.Fatalf("id/turnId = %q/%q,want web-3#turn-a / turn-a(同源)", id, turnID)
}
if round != 1 {
t.Fatalf("round = %d,want 1(轮次号与会话级序号是两个量)", round)
}
// 无路的图:`turnId` 必须**缺席**(omitempty),客户端据此回落到「会话那一张」。
plain := trace.NewRunGraph("web")
plain.Start(4, time.Now())
plainRaw, err := json.Marshal(plain.Snapshot())
if err != nil {
t.Fatalf("marshal plain: %v", err)
}
var plainGot map[string]json.RawMessage
if err := json.Unmarshal(plainRaw, &plainGot); err != nil {
t.Fatalf("decode plain: %v", err)
}
if _, ok := plainGot["turnId"]; ok {
t.Fatalf("单路的帧不该带 `turnId` 键:%s", plainRaw)
}
var plainRound int
_ = json.Unmarshal(plainGot["round"], &plainRound)
if plainRound != 4 {
t.Fatalf("单路 round = %d,want 4", plainRound)
}
}
// TestFlowGraphSourceLanesIsReadOnly 钉住 `lanes[]` 的只读性:补拉快照**不会**发布
// 任何新帧、也不会改任何路的图(与 LastGraph 同一纪律)。
func TestFlowGraphSourceLanesIsReadOnly(t *testing.T) {
published := 0
pub := loop.NewFlowPublisher(func(string, any) { published++ }, func(string) bool { return true })
pub.StartRound(loop.FlowOwnerKey("web", "turn-a"), "标记A")
before := published
v := pub.LatestLanes("web")
if len(v) == 0 {
t.Fatal("LatestLanes 为空,但该会话刚起了一路")
}
if published != before {
t.Fatalf("LatestLanes 发布了 %d 帧新图(只读查询绝不能造帧)", published-before)
}
// 快照是深拷贝:改它不能碰到发布器内部的图。
v[0].Nodes = append(v[0].Nodes, trace.FlowNode{ID: "ghost", Kind: "input"})
again := pub.LatestLanes("web")
for _, g := range again {
for _, n := range g.Nodes {
if n.ID == "ghost" {
t.Fatal("读者追加的节点漏进了发布器内部的图 ⇒ 不是深拷贝")
}
}
}
}
// 保证 flowTestEnv 的图形源仍实现 FlowGraphSource(v0.77 多了 LatestLanes)。
var _ FlowGraphSource = (*loop.FlowPublisher)(nil)