fix(flow): graph_update 按路分桶——修真并行两路的图互相覆盖(FV2)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

【取证(症状成立,且比登记描述更糟)】两路真并行时两路最终落盘**同一个 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) 提交里(同一个工作时段内)。
This commit is contained in:
AskaEth committed 2026-10-06 08:16:03 +08:00
1 parent c9144c1096
commit 89434eadef
30 files changed
+2039 -148

No files matched your search

+232
View File
@@ -0,0 +1,232 @@
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)
}
}
}