【取证(症状成立,且比登记描述更糟)】两路真并行时两路最终落盘**同一个 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) 提交里(同一个工作时段内)。
122 lines
4.4 KiB
Go
122 lines
4.4 KiB
Go
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())
|
||
}
|
||
}
|
||
}
|
||
}
|