Files
Cyrene_v2/internal/runtime/orchestration/spawnflow_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

122 lines
4.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 orchestration
import (
"context"
"strings"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// 本文件钉住**真并行路里派发的子代理落进那一路的图**(切片 FV2;design/15 §2.5.10-1)。
//
// 症状:同一会话真并行时两路各有一张图,而子代理的 span 归属取自 `AgentSpec.ParentSession`
// —— 那是**会话**(总线载荷要用它),不含路 ⇒ 子代理的泳道会落进「会话主干那张」,而那张图
// 在纯并行轮次里根本不存在(span 被静默丢弃),或者落进另一路。这里断言的是:派发工具从
// ctx 取出的**归属桶**(`{会话}#{路}`)一路传到发布器,泳道根只长在**那一路**的图上。
// laneGraphCapture 按 (会话, 路) 记录发布器发出的每一张图(路的键就是帧上的 `turnId`)。
type laneGraphCapture struct {
mu sync.Mutex
lanes map[string]*trace.FlowGraph
}
func laneKey(session, turnID string) string { return session + "|" + turnID }
func (c *laneGraphCapture) publish(_ string, payload any) {
g, ok := payload.(*trace.FlowGraph)
if !ok || g == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
if c.lanes == nil {
c.lanes = make(map[string]*trace.FlowGraph)
}
c.lanes[laneKey(g.SessionID, g.TurnID)] = g
}
// lane 返回某一路最新那张图;不存在时判测试失败(并列出收到过哪些路)。
func (c *laneGraphCapture) lane(t *testing.T, session, turnID string) *trace.FlowGraph {
t.Helper()
c.mu.Lock()
defer c.mu.Unlock()
g := c.lanes[laneKey(session, turnID)]
if g == nil {
t.Fatalf("没有收到会话 %q 路 %q 的图(收到过的路:%v)", session, turnID, c.keysLocked())
}
return g
}
func (c *laneGraphCapture) keysLocked() []string {
out := make([]string, 0, len(c.lanes))
for k := range c.lanes {
out = append(out, k)
}
return out
}
// branchOf 造一个「运行在某一路上」的 ctx(网关经 WithParallelLeg 盖上会话 + 路;
// 子代理自身则经 WithFlowBranch 带着父归属桶)。两条通道的 `FlowOwnerFromContext`
// 取值同源,因此派发工具的取值路径只有这一条。
func branchOf(session, lane string) context.Context {
return loop.WithFlowBranch(context.Background(), loop.FlowOwnerKey(session, lane))
}
// TestSpawnToolBindsSubAgentToItsLaneGraph 是核心断言:在一路里派发的子代理,其泳道根
// 只出现在**那一路**的图上,且会话主干那张图**不存在**(=没有被静默丢弃或错落他路)。
func TestSpawnToolBindsSubAgentToItsLaneGraph(t *testing.T) {
t.Parallel()
capture := &laneGraphCapture{}
flow := loop.NewFlowPublisher(capture.publish, nil)
tracer := trace.New(nil)
d, err := tool.New(registry.New())
if err != nil {
t.Fatalf("tool.New: %v", err)
}
if rerr := d.Register(&echoTool{name: "echo", result: "ok"}); rerr != nil {
t.Fatalf("Register: %v", rerr)
}
s := branchLoop(t, flow, tracer, d, Options{MaxParallel: 2})
// 这一路已经开跑(网关在调用 Run 之前就发过 turn_begin,图的归属桶因此已存在)。
owner := loop.FlowOwnerKey(parentSession, "turn-a")
flow.StartRound(owner, "并行的一路")
sp := NewSpawnTool(s, WithSpawnWait(5*time.Second))
if _, xerr := sp.Execute(branchOf(parentSession, "turn-a"), map[string]any{"goal": "调研一下"}); xerr != nil {
t.Fatalf("派发: %v", xerr)
}
flow.EndRound(owner, "结果如下")
g := capture.lane(t, parentSession, "turn-a")
roots := 0
for _, n := range g.Nodes {
if n.Kind == "subagent" {
roots++
}
}
if roots != 1 {
t.Fatalf("该路的图里子代理泳道根 = %d 个,want 1(nodes=%v)", roots, nodeIDs(g))
}
if strings.HasSuffix(g.ID, "#turn-a") == false {
t.Fatalf("该路的图 id = %q,want 带 `#turn-a` 后缀", g.ID)
}
// 会话主干那张(无路标识)绝不该被这一路的子代理污染:它要么不存在,要么不含泳道根。
capture.mu.Lock()
trunk := capture.lanes[laneKey(parentSession, "")]
capture.mu.Unlock()
if trunk != nil {
for _, n := range trunk.Nodes {
if n.Kind == "subagent" {
t.Fatalf("子代理泳道根长在了会话主干图上(路桶 %v 才是它该在的地方)", capture.keysLocked())
}
}
}
}