Files
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

135 lines
4.9 KiB
Go
Raw Permalink 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 flowgraph
// 本文件钉住**真并行两路的落盘不撞 key**(design/15 §2.5.10-1 + design/19 §4 P3;
// 切片 FV2)。症状:两路在同一会话各收一轮,若两路的 `turn_id` 相同,
// `(session_id, turn_id)` 唯一键会让两路 upsert 成**一行** —— 后收尾的那一路把前一路
// 整张图顶掉,翻历史轮时那一路的图彻底消失。
//
// 断言的是**库里的事实**(几行、每行 id 是什么、读回来的图属于哪一路),不按日志断言,
// 与 flowgraph_test.go 同一口径。夹具用 `trace.RunGraph` 走真建图路径(两路各自
// SetTurnID + SetSeq),因此库里的 turn_id 与线上图 id 逐字同源。
import (
"context"
"database/sql"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// laneGraph 用真建图器造一条路的收尾图:seq 是会话级建图序号(两路必不同),
// lane 是这一路的权威 turnId。
func laneGraph(session string, seq int, lane, input, output string) *trace.FlowGraph {
g := trace.NewRunGraph(session)
g.SetTurnID(lane)
g.SetSeq(seq)
g.Start(seq, testNow().Add(-time.Minute))
g.SeedInput(input)
g.Upsert(trace.NodeSpec{ID: "span-" + lane, Label: lane, Kind: "tool", Status: trace.FlowStatusDone, ParentID: "input"})
g.SeedOutput(output)
return g.Snapshot()
}
// TestStore_ParallelLanesPersistSeparateRows_正常 是本文件的核心断言:同一会话同一轮
// 的两路各占**一行**,按各自的路标识查得回来,`Turns` 列出两条,任一路都没有被
// 另一路顶掉。
func TestStore_ParallelLanesPersistSeparateRows_正常(t *testing.T) {
t.Parallel()
st, conn := newTestStore(t, nil)
ctx := context.Background()
laneA := laneGraph("web", 1, "turn-a", "标记A", "答复A")
laneB := laneGraph("web", 2, "turn-b", "标记B", "答复B")
if laneA.ID == laneB.ID {
t.Fatalf("夹具自身就撞了 id:%q", laneA.ID)
}
for _, g := range []*trace.FlowGraph{laneA, laneB} {
if err := st.RecordRound(g); err != nil {
t.Fatalf("落盘 %s: %v", g.ID, err)
}
}
if got := countRows(t, conn, "web"); got != 2 {
t.Fatalf("flow_graphs 里该会话的行数 = %d,want 2(两路各一行)", got)
}
for _, want := range []struct {
graph *trace.FlowGraph
input string
output string
}{{laneA, "标记A", "答复A"}, {laneB, "标记B", "答复B"}} {
got, err := st.Graph(ctx, "web", want.graph.ID)
if err != nil {
t.Fatalf("查 %s: %v", want.graph.ID, err)
}
if got == nil {
t.Fatalf("查不到 %s —— 这一路被另一路顶掉了", want.graph.ID)
}
if got.TurnID != want.graph.TurnID || got.Round != want.graph.Round {
t.Fatalf("读回 %s 的路/轮次 = %q/%d,want %q/%d",
want.graph.ID, got.TurnID, got.Round, want.graph.TurnID, want.graph.Round)
}
if !hasNodeLabel(got, want.input) || !hasNodeLabel(got, want.output) {
t.Fatalf("读回 %s 的图不是这一路的(输入/回复节点对不上):%+v", want.graph.ID, got.Nodes)
}
}
turns, err := st.Turns(ctx, "web", 0)
if err != nil {
t.Fatalf("Turns: %v", err)
}
if len(turns) != 2 {
t.Fatalf("Turns 条数 = %d,want 2(两路都能翻到)", len(turns))
}
if latest, err := st.Latest(ctx, "web"); err != nil || latest == nil {
t.Fatalf("Latest = %+v / %v,want 一条真图", latest, err)
}
}
// TestStore_SameLaneTwiceIsOneRow_边界 钉住唯一键的另一半语义:**同一路**二次收尾
// 是同轮覆盖(一行),这正是分桶改造**不该改动**的既有行为 —— 两路分行靠的是两路
// 各有各的 id,而不是靠放弃 upsert。
func TestStore_SameLaneTwiceIsOneRow_边界(t *testing.T) {
t.Parallel()
st, conn := newTestStore(t, nil)
first := laneGraph("web", 7, "turn-x", "标记A", "答复A")
if err := st.RecordRound(first); err != nil {
t.Fatalf("首次落盘: %v", err)
}
again := laneGraph("web", 7, "turn-x", "标记A", "答复A(重放)")
if err := st.RecordRound(again); err != nil {
t.Fatalf("二次落盘: %v", err)
}
if got := countRows(t, conn, "web"); got != 1 {
t.Fatalf("同一路二次收尾后行数 = %d,want 1(同轮覆盖)", got)
}
back, err := st.Graph(context.Background(), "web", first.ID)
if err != nil || back == nil {
t.Fatalf("Graph = %+v / %v", back, err)
}
if !hasNodeLabel(back, "答复A(重放)") {
t.Fatalf("覆盖后的图不是最新那份:%+v", back.Nodes)
}
}
// countRows 数该会话在 flow_graphs 里的行数(结构级断言,不经过 store 的读方法)。
func countRows(t *testing.T, conn *sql.DB, session string) int {
t.Helper()
var n int
if err := conn.QueryRowContext(context.Background(),
`SELECT COUNT(*) FROM flow_graphs WHERE session_id = ?`, session).Scan(&n); err != nil {
t.Fatalf("COUNT 行数: %v", err)
}
return n
}
// hasNodeLabel 报告图里有没有标签等于 label 的节点。
func hasNodeLabel(g *trace.FlowGraph, label string) bool {
for _, n := range g.Nodes {
if n.Label == label {
return true
}
}
return false
}