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

293 lines
11 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"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// 本文件是**真并行两路互不覆盖**的回归(design/15 §2.5.10-1 登记的那条真 bug):
// 同一会话的两条并行路(`chat.parallel`)各自开一轮,`graphs[sessionID]` 只有一张 ⇒
// 后起的那一路把前一路的图整张顶掉、两路的节点互相长在对方的图上、落盘两路撞同一个
// `flow_graphs.turn_id`。
//
// 用例刻意只用**引入本切片之前就存在**的 API 驱动真实路径(真 `Loop.Run` + 网关实际
// 挂的那两条 ctx 通道 `WithTurnID` / `WithParallelLeg`),因此它在修复前能编译、能跑、
// 会红 —— 「改前红」的证据就是它(见 FV2-report §4)。判据:
//
// - 两条路各落一条(`RecordRound` 两次)且**图 id 不同**(同一 turn_id 会被
// `(session_id, turn_id)` 唯一键 upsert 成一行);
// - 以 A 的输入播种的那张图里只有 A 的工具节点,反之亦然;
// - 任何一帧 `graph_update` 都不同时含两条路的工具节点。
// barrierTool 是执行期停在闸门上的工具:它报告「已到执行点」后阻塞,使测试能确定地
// 构造「A 的工具在飞时 B 起步」的交错时序,而不是靠 sleep 赌调度。
type barrierTool struct {
name string
arrived chan struct{}
gate chan struct{}
once sync.Once
}
func newBarrierTool(name string) *barrierTool {
return &barrierTool{name: name, arrived: make(chan struct{}, 1), gate: make(chan struct{})}
}
func (b *barrierTool) Name() string { return b.name }
func (b *barrierTool) Description() string { return "barrier tool " + b.name }
func (b *barrierTool) Parameters() (map[string]any, error) {
return map[string]any{"type": "object", "properties": map[string]any{}}, nil
}
func (b *barrierTool) Execute(ctx context.Context, _ any) (any, error) {
b.once.Do(func() { b.arrived <- struct{}{} })
select {
case <-b.gate:
return b.name + " 完成", nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
// laneAdapter 按「这一路的窗口里是哪个标记文本」分流,先请求该路的工具,工具结果回灌
// 之后给出该路的最终答复。两条路共用一个实例 —— 与生产同形(同一个 runner 跑两路)。
type laneAdapter struct {
tool map[string]string // 标记文本 → 该路请求的工具名
answer map[string]string // 标记文本 → 该路的最终答复
}
func (a *laneAdapter) Name() string { return "lane" }
func (a *laneAdapter) Chat(_ context.Context, req model.Request, _ model.Options) (model.Response, error) {
mark := a.markOf(req)
if mark == "" {
return model.Response{Content: "无法归属的请求"}, nil
}
if lastMessageRole(req.Messages) == model.RoleTool {
return model.Response{Content: a.answer[mark]}, nil
}
return model.Response{Content: `{"tool_calls":[{"name":"` + a.tool[mark] + `","args":{}}]}`}, nil
}
// markOf 找出请求里出现的那一路的标记文本;两路的窗口互不共享(I4),因此最多一命中。
func (a *laneAdapter) markOf(req model.Request) string {
for _, m := range req.Messages {
if m.Role != model.RoleUser {
continue
}
for mark := range a.tool {
if strings.Contains(messageText(m), mark) {
return mark
}
}
}
return ""
}
// ChatStream 是流式那一半:本用例的循环走非流式路径(未挂 OnStream),因此它只把
// 请求委托给 Chat 并原样交出聚合结果 —— 刻意**不**再造第二套脚本,也刻意不发任何
// 增量事件,避免「哪个入口真的跑过」变成模糊事实。
func (a *laneAdapter) ChatStream(ctx context.Context, req model.Request, _ func(model.StreamEvent)) (model.Response, error) {
return a.Chat(ctx, req, model.Options{})
}
// lastMessageRole 返回末条消息的角色(空窗口时为空串)。
func lastMessageRole(msgs []model.Message) model.Role {
if len(msgs) == 0 {
return ""
}
return msgs[len(msgs)-1].Role
}
// parallelLaneFixture 是两路交错跑完之后的全部观测面:落盘的每一路、发布的每一帧、
// 以及两路各自的最终答复(用来确认哪张图属于哪一路)。
type parallelLaneFixture struct {
pub *FlowPublisher
recorder *fakeRecorder
mu sync.Mutex
frames []*trace.FlowGraph
}
// run 让两条平行路交错跑完一轮:A 起轮 → A 的工具在飞 → B 起轮 → B 的工具在飞 →
// 依次放行。返回时两路的 Run 都已返回。
func (f *parallelLaneFixture) run(t *testing.T) {
t.Helper()
toolA, toolB := newBarrierTool("probe_a"), newBarrierTool("probe_b")
ad := &laneAdapter{
tool: map[string]string{"标记A": "probe_a", "标记B": "probe_b"},
answer: map[string]string{"标记A": "答复A", "标记B": "答复B"},
}
l := New(Options{
Agent: newTestAgent(t, ad, dispatcherWith(t, toolA, toolB)),
MaxRounds: 8,
Flow: f.pub,
Tracer: trace.New(nil),
})
ctxA := WithParallelLeg(WithTurnID(context.Background(), "turn-a"), "turn-a")
ctxB := WithParallelLeg(WithTurnID(context.Background(), "turn-b"), "turn-b")
var wg sync.WaitGroup
var resA, resB *Result
var errA, errB error
wg.Add(2)
go func() {
defer wg.Done()
resA, errA = l.Run(ctxA, "web", []model.Message{model.TextMessage(model.RoleUser, "标记A:请跑一次工具")})
}()
// A 的工具确实在飞之后才起 B —— 这才叫「同一会话两路并行」,也正是缺陷现场。
awaitBarrier(t, toolA.arrived, "A 的工具执行点")
go func() {
defer wg.Done()
resB, errB = l.Run(ctxB, "web", []model.Message{model.TextMessage(model.RoleUser, "标记B:请跑一次工具")})
}()
awaitBarrier(t, toolB.arrived, "B 的工具执行点")
close(toolA.gate)
close(toolB.gate)
wg.Wait()
if errA != nil || errB != nil {
t.Fatalf("两路 Run 报错:A=%v B=%v", errA, errB)
}
if resA.Text != "答复A" || resB.Text != "答复B" {
t.Fatalf("两路答复 = %q / %q,want 答复A / 答复B(窗口串了)", resA.Text, resB.Text)
}
}
// awaitBarrier 等一个闸门信号,超时即让测试失败(避免缺陷把它挂死成超时)。
func awaitBarrier(t *testing.T, ch <-chan struct{}, what string) {
t.Helper()
select {
case <-ch:
case <-time.After(20 * time.Second):
t.Fatalf("等待 %s 超时", what)
}
}
// newParallelLaneFixture 装一台接了假落盘出口的发布器(真 bus 不在本用例范围内)。
func newParallelLaneFixture(t *testing.T) *parallelLaneFixture {
t.Helper()
f := &parallelLaneFixture{recorder: &fakeRecorder{}}
f.pub = NewFlowPublisher(func(subject string, payload any) {
if subject != graphUpdateSubject {
return
}
g, ok := payload.(*trace.FlowGraph)
if !ok {
return
}
f.mu.Lock()
f.frames = append(f.frames, g)
f.mu.Unlock()
}, func(string) bool { return true }, WithRoundRecorder(f.recorder))
return f
}
// recorded 返回落盘出口收到的每一路图(副本,调用方无需持锁)。
func (f *parallelLaneFixture) recorded() []*trace.FlowGraph {
return recordedRounds(f.recorder)
}
// recordedRounds 返回假落盘出口收到的每一路图(副本,调用方无需持锁)。
func recordedRounds(rec *fakeRecorder) []*trace.FlowGraph {
rec.mu.Lock()
defer rec.mu.Unlock()
return append([]*trace.FlowGraph(nil), rec.graphs...)
}
// publishedFrames 返回发布过的每一帧(副本)。
func (f *parallelLaneFixture) publishedFrames() []*trace.FlowGraph {
f.mu.Lock()
defer f.mu.Unlock()
return append([]*trace.FlowGraph(nil), f.frames...)
}
// TestFlowPublisher_ParallelLegsDoNotOverwriteEachOther 是本切片的核心用例:
// 两路并行 ⇒ 两桶互不覆盖、各自落一条、任何一帧都不含对方节点。
func TestFlowPublisher_ParallelLegsDoNotOverwriteEachOther(t *testing.T) {
f := newParallelLaneFixture(t)
f.run(t)
recorded := f.recorded()
if len(recorded) != 2 {
t.Fatalf("落盘 %d 条,want 2(每路一条):%s", len(recorded), describeGraphs(recorded))
}
if recorded[0].ID == recorded[1].ID {
t.Fatalf("两路撞同一个 turn_id %q ⇒ flow_graphs 的 (session_id, turn_id) 会把其中一路整行顶掉:%s",
recorded[0].ID, describeGraphs(recorded))
}
graphA := graphWithInput(recorded, "标记A")
graphB := graphWithInput(recorded, "标记B")
if graphA == nil || graphB == nil {
t.Fatalf("两路没有各自的图(A=%v B=%v):%s", graphA != nil, graphB != nil, describeGraphs(recorded))
}
assertLaneIsolated(t, "A", graphA, "probe_a", "probe_b")
assertLaneIsolated(t, "B", graphB, "probe_b", "probe_a")
for i, frame := range f.publishedFrames() {
if hasNode(frame, "probe_a") && hasNode(frame, "probe_b") {
t.Fatalf("第 %d 帧同时含两条路的工具节点(B 的节点长在 A 的图上):%s", i, describeNodes(frame))
}
}
}
// assertLaneIsolated 断言某一路的图里有自己的工具节点、且**没有**对方的工具节点。
func assertLaneIsolated(t *testing.T, lane string, g *trace.FlowGraph, own, other string) {
t.Helper()
if !hasNode(g, own) {
t.Fatalf("%s 路的图里没有自己的工具节点 %q:%s", lane, own, describeNodes(g))
}
if hasNode(g, other) {
t.Fatalf("%s 路的图里出现了另一路的工具节点 %q:%s", lane, other, describeNodes(g))
}
if g.SessionID != "web" {
t.Fatalf("%s 路图的 sessionId = %q,want web(路不能改会话归属)", lane, g.SessionID)
}
}
// graphWithInput 找出输入节点标签含 mark 的那张图(两路各自被用户文本播种)。
func graphWithInput(graphs []*trace.FlowGraph, mark string) *trace.FlowGraph {
for _, g := range graphs {
for _, n := range g.Nodes {
if n.Kind == "input" && strings.Contains(n.Label, mark) {
return g
}
}
}
return nil
}
// hasNode 报告图里有没有标签等于(或前缀为)name 的工具节点。
func hasNode(g *trace.FlowGraph, name string) bool {
for _, n := range g.Nodes {
if n.Kind == "tool" && strings.Contains(n.Label, name) {
return true
}
}
return false
}
// describeGraphs / describeNodes 只打印**结构与标签**,供失败信息定位(测试里没有
// 敏感内容,但仍只打标识)。
func describeGraphs(graphs []*trace.FlowGraph) string {
var b strings.Builder
for _, g := range graphs {
b.WriteString("\n id=" + g.ID + " session=" + g.SessionID)
b.WriteString(" nodes=" + describeNodes(g))
}
return b.String()
}
func describeNodes(g *trace.FlowGraph) string {
parts := make([]string, 0, len(g.Nodes))
for _, n := range g.Nodes {
parts = append(parts, n.Kind+":"+n.Label)
}
return "[" + strings.Join(parts, " ") + "]"
}