Files
Cyrene_v2/internal/runtime/loop/flow_lane_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

233 lines
10 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 loop
import (
"context"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// 本文件钉住**按路分桶**这条改造的其余口径(核心行为已在 flow_parallel_test.go 用真
// 循环跑过):桶 / 图 id / 帧上 `turnId` 键三者同源,最近一轮的每一路都拿得回来,
// 且新一轮开始后不再把上一轮的路留在内存里。这些用例走发布器的公开面(真调用点
// 传给它的就是这些字符串),不依赖任何数据库。
// laneIDs 返回每一路图的 id 与 turnId 键,供断言用。
func laneIDs(lanes []*trace.FlowGraph) []string {
out := make([]string, 0, len(lanes))
for _, g := range lanes {
out = append(out, g.ID+"@"+g.TurnID)
}
return out
}
// TestFlowPublisher_ParallelLanesGetOwnGraphs 钉住真并行两路各有各的桶:
// 图 id 不同(落盘不撞 key)、都归属同一个会话、都带上自己的路标识(帧上 `turnId`),
// 且最近一轮的两路都能经 LatestLanes 拿回来。
func TestFlowPublisher_ParallelLanesGetOwnGraphs(t *testing.T) {
p := NewFlowPublisher(nil, func(string) bool { return true })
p.StartRound(FlowOwnerKey("web", "turn-a"), "标记A")
p.SpanStarted(FlowOwnerKey("web", "turn-a"), spanNamed("span-a", "tool.probe_a"))
p.SpanFinished(FlowOwnerKey("web", "turn-a"), finishedSpan("span-a", "tool.probe_a"))
p.StartRound(FlowOwnerKey("web", "turn-b"), "标记B")
p.SpanStarted(FlowOwnerKey("web", "turn-b"), spanNamed("span-b", "tool.probe_b"))
p.SpanFinished(FlowOwnerKey("web", "turn-b"), finishedSpan("span-b", "tool.probe_b"))
ga := p.LastGraph("web")
if ga == nil {
t.Fatal("LastGraph = nil,但该会话刚起了两路")
}
// 最近一路 = 后起的那一路;它的图里只有自己的工具。
if ga.TurnID != "turn-b" || !hasNode(ga, "probe_b") || hasNode(ga, "probe_a") {
t.Fatalf("LastGraph 不是 B 那一路的图:id=%s turnId=%q nodes=%s", ga.ID, ga.TurnID, describeNodes(ga))
}
lanes := p.LatestLanes("web")
if len(lanes) != 2 {
t.Fatalf("最近一轮的路数 = %d,want 2(两路各自一张图):%v", len(lanes), laneIDs(lanes))
}
if got := laneIDs(lanes); got[0] != "web-1#turn-a@turn-a" || got[1] != "web-2#turn-b@turn-b" {
t.Fatalf("两路的 id@turnId = %v,want [web-1#turn-a@turn-a web-2#turn-b@turn-b]", got)
}
if lanes[0].ID == lanes[1].ID {
t.Fatalf("两路撞同一个图 id %q(落盘会互相顶掉)", lanes[0].ID)
}
for _, g := range lanes {
if g.SessionID != "web" {
t.Fatalf("图 %s 的 sessionId = %q,want web(路标识绝不改变会话归属)", g.ID, g.SessionID)
}
if g.Round != lanes[0].Round {
t.Fatalf("同一轮的两路轮次号不同:%d vs %d", g.Round, lanes[0].Round)
}
}
// 收尾后仍是最近一轮的每一路(页面在这之后才打开也看得到两条)。
p.EndRound(FlowOwnerKey("web", "turn-a"), "答复A")
p.EndRound(FlowOwnerKey("web", "turn-b"), "答复B")
if got := p.LatestLanes("web"); len(got) != 2 {
t.Fatalf("收轮后最近一轮的路数 = %d,want 2", len(got))
}
}
// TestFlowPublisher_NewRoundDropsEarlierLanes 钉住轮次推进与内存边界:新一轮只在
// 上一轮的路全部收尾之后开始(该轮的路因此共享轮次号),并丢掉更早轮次的桶 ——
// 否则每一条真并行的路(turnId 每回合全新、桶键永不复用)都会在内存里永久留一张图。
func TestFlowPublisher_NewRoundDropsEarlierLanes(t *testing.T) {
p := NewFlowPublisher(nil, func(string) bool { return true })
p.StartRound(FlowOwnerKey("web", "turn-a"), "标记A")
p.StartRound(FlowOwnerKey("web", "turn-b"), "标记B")
round := p.LatestLanes("web")[0].Round
p.EndRound(FlowOwnerKey("web", "turn-a"), "答复A")
p.EndRound(FlowOwnerKey("web", "turn-b"), "答复B")
// 上一轮尚未被新轮次顶掉时,两条路都还在。
if got := p.LatestLanes("web"); len(got) != 2 {
t.Fatalf("上一轮的路数 = %d,want 2(收轮后仍可补拉)", len(got))
}
p.StartRound("web", "单路的下一轮")
next := p.LatestLanes("web")
if len(next) != 1 || next[0].TurnID != "" {
t.Fatalf("新一轮的路 = %v,want 恰好一条无路标识的图(上一轮的桶必须被丢掉)", laneIDs(next))
}
if next[0].Round <= round {
t.Fatalf("新一轮轮次号 = %d,want > %d", next[0].Round, round)
}
if g := p.LastGraph("web"); g == nil || g.TurnID != "" {
t.Fatalf("新一轮的 LastGraph = %+v,want 单路那张(上一轮的路已不在内存)", g)
}
}
// TestFlowPublisher_RoundsShareRoundNumberButNeverID 钉住「轮次号」与「图 id 的序号」
// 是两个量:同一轮里可以有**不止一条路**(真并行的那一路 + 队首续跑的串行路),它们
// 共享轮次号(客户端据此把同一轮的路并排画),但**各有各的图 id** —— 否则落盘的
// `(session_id, turn_id)` 唯一键会把同一会话的两轮 upsert 成一行,前一轮整张图消失。
func TestFlowPublisher_RoundsShareRoundNumberButNeverID(t *testing.T) {
rec := &fakeRecorder{}
p := NewFlowPublisher(nil, func(string) bool { return true }, WithRoundRecorder(rec))
// 一条并行路先起(占用本轮),随后两条串行路在它仍飞着的时候先后跑完。
p.StartRound(FlowOwnerKey("web", "turn-a"), "并行的一路")
p.StartRound("web", "串行第一轮")
p.EndRound("web", "答复1")
p.StartRound("web", "串行第二轮")
p.EndRound("web", "答复2")
lines := recordedRounds(rec)
if len(lines) != 2 {
t.Fatalf("落盘 %d 条,want 2(两条串行路各一条)", len(lines))
}
if lines[0].ID == lines[1].ID {
t.Fatalf("同一轮的两条串行路撞同一个 id %q ⇒ 落盘会把前一轮整行顶掉", lines[0].ID)
}
if lines[0].Round != lines[1].Round {
t.Fatalf("同一轮的两条路轮次号不同:%d vs %d", lines[0].Round, lines[1].Round)
}
}
// TestFlowPublisher_SingleLegUnchanged 钉住**单路逐字不变**:归属桶就是会话 id,
// 图 id 仍是 `{会话}-{轮次}`(没有路后缀),帧上 `turnId` 键缺席。既有回归
// (flow_snapshot_test.go 的 web-1/web-2)与本条是同一口径的两侧。
func TestFlowPublisher_SingleLegUnchanged(t *testing.T) {
p := NewFlowPublisher(nil, func(string) bool { return true })
p.StartRound("web", "你好")
p.EndRound("web", "你也好")
g := p.LastGraph("web")
if g == nil || g.ID != "web-1" || g.TurnID != "" {
t.Fatalf("单路图 = %+v,want id=web-1 且无 turnId(逐字不变)", g)
}
}
// TestFlowOwnershipHelpers 钉住 loop 与 orchestration 之间的归属口径:
// 真并行路由 `{会话}#{路}` 归属、真会话 id 与路标识各自可单独取出(总线载荷要的是
// 会话、图归属要的是桶),子代理分支继承派生它的那一路。
func TestFlowOwnershipHelpers(t *testing.T) {
// 与 Run 同形:会话先经 withLoopSession 播种,网关再加路标识(WithParallelLeg)。
ctx := withLoopSession(WithParallelLeg(WithTurnID(context.Background(), "turn-a"), "turn-a"), "web")
if got := flowOwner(ctx, "web"); got != "web#turn-a" {
t.Fatalf("并行路的归属桶 = %q,want web#turn-a", got)
}
if got := FlowOwnerFromContext(ctx); got != "web#turn-a" {
t.Fatalf("FlowOwnerFromContext = %q,want web#turn-a", got)
}
if got := FlowSessionFromContext(ctx); got != "web" {
t.Fatalf("FlowSessionFromContext = %q,want web(总线载荷要真会话 id)", got)
}
if got := FlowLaneFromContext(ctx); got != "turn-a" {
t.Fatalf("FlowLaneFromContext = %q,want turn-a", got)
}
// 子代理(分支 Run):归属继承分支桶,会话与路各自拆得回来。
branch := WithFlowBranch(context.Background(), FlowOwnerFromContext(ctx))
if got := flowOwner(branch, "sub-1"); got != "web#turn-a" {
t.Fatalf("子代理 span 的归属桶 = %q,want web#turn-a(必须落进派生它的那一路)", got)
}
if got := FlowSessionFromContext(branch); got != "web" {
t.Fatalf("子代理的 FlowSessionFromContext = %q,want web", got)
}
if got := FlowLaneFromContext(branch); got != "turn-a" {
t.Fatalf("子代理的 FlowLaneFromContext = %q,want turn-a", got)
}
// 单路:桶、会话、路三者不出意外的形态(路为空)。
single := withLoopSession(context.Background(), "web")
if got := flowOwner(single, "web"); got != "web" {
t.Fatalf("单路归属桶 = %q,want web", got)
}
if got := FlowOwnerFromContext(single); got != "web" {
t.Fatalf("单路 FlowOwnerFromContext = %q,want web", got)
}
if got := FlowLaneFromContext(single); got != "" {
t.Fatalf("单路 FlowLaneFromContext = %q,want 空", got)
}
// 没有会话就没有归属:绝不回 `#路` 这种既查不到图、又骗调用方的串。
if got := FlowOwnerFromContext(WithParallelLeg(context.Background(), "turn-a")); got != "" {
t.Fatalf("无会话时 FlowOwnerFromContext = %q,want 空", got)
}
if got := FlowSessionFromContext(WithParallelLeg(context.Background(), "turn-a")); got != "" {
t.Fatalf("无会话时 FlowSessionFromContext = %q,want 空", got)
}
if _, lane := splitFlowOwner("web"); lane != "" {
t.Fatalf("无 # 的归属串应拆出空路,得到 %q", lane)
}
}
// spanNamed / finishedSpan 造一个刚起 / 刚结束的 span,让图里有真实的节点。
func spanNamed(id, name string) *trace.Span {
return &trace.Span{ID: id, Name: name, Kind: trace.SpanKindTool}
}
func finishedSpan(id, name string) *trace.Span {
sp := spanNamed(id, name)
sp.Finish(trace.SpanStatusOK)
return sp
}
// TestFlowPublisher_LaneFramesCarryLaneKey 钉住**帧载荷**上的 `turnId` 键:
// 客户端据此把两路的帧分成两条泳道(旧客户端忽略未知键仍可用)。
func TestFlowPublisher_LaneFramesCarryLaneKey(t *testing.T) {
var frames []*trace.FlowGraph
p := NewFlowPublisher(func(_ string, payload any) {
if g, ok := payload.(*trace.FlowGraph); ok {
frames = append(frames, g)
}
}, func(string) bool { return true })
p.StartRound(FlowOwnerKey("web", "turn-a"), "标记A")
p.StartRound(FlowOwnerKey("web", "turn-b"), "标记B")
if len(frames) < 2 {
t.Fatalf("发布帧数 = %d,want ≥2", len(frames))
}
for _, g := range frames {
if g.TurnID == "" {
t.Fatalf("并行路的帧缺 turnId 键(客户端无法分成两条泳道):id=%s", g.ID)
}
if !strings.Contains(g.ID, g.TurnID) {
t.Fatalf("图 id %q 里找不到路标识 %q(两者必须同源)", g.ID, g.TurnID)
}
if g.Round == 0 {
t.Fatalf("帧缺轮次号:id=%s", g.ID)
}
}
}