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) 提交里(同一个工作时段内)。
This commit is contained in:
1 parent
c9144c1096
commit
89434eadef
30 files changed
+2039
-148
No files matched your search
@@ -139,6 +139,7 @@ func (s *Scheduler) Spawn(ctx context.Context, spec AgentSpec) (*SubAgent, error
|
||||
Mode: spec.Mode,
|
||||
AttachInfo: spec.AttachInfo,
|
||||
ParentSession: spec.ParentSession,
|
||||
FlowOwner: spec.FlowOwner,
|
||||
Data: spec.Data,
|
||||
ToolWhitelist: cloneStrings(spec.ToolWhitelist),
|
||||
Context: taskContext(spec),
|
||||
@@ -295,9 +296,10 @@ func (s *Scheduler) run(ctx context.Context, sub *SubAgent) {
|
||||
// 子代理不得把内心独白泄漏进调用方的聊天流
|
||||
// (design/05 §8: 少说话、多做事; design/04 §2: 不污染前台):静音抑制
|
||||
// UI 流/容量广播,而进度仍经总线与泳道到达前端。flow 分支把本次 Run 发射的
|
||||
// 每个 span 都归属到派发会话的图,而不是另开第二张图。
|
||||
// 每个 span 都归属到派发**那一路**的图(`{会话}[#{路}]`,见 SubAgent.flowOwner),
|
||||
// 而不是另开第二张图、也不是落进会话主干那张。
|
||||
runCtx = loop.WithMutedBroadcast(runCtx)
|
||||
runCtx = loop.WithFlowBranch(runCtx, sub.ParentSession)
|
||||
runCtx = loop.WithFlowBranch(runCtx, sub.flowOwner())
|
||||
// 被派发的目标角色决定本次运行每一步的提示词与记忆作用域(design/22 §8-2)。
|
||||
// 留空则保持继承来的 env:既有行为,即子代理按派发回合的角色运行。
|
||||
runCtx = withPersonaEnv(runCtx, sub.PersonaID)
|
||||
@@ -370,18 +372,18 @@ func (s *Scheduler) subCtx(parent context.Context, sub *SubAgent) (context.Conte
|
||||
}
|
||||
|
||||
// subSpan 在配置了 tracer 时开启一个子代理 span,把它串成 ctx 中已有 span 的子节点。
|
||||
// 子代理被归属到某个 flow 会话(AgentSpec.ParentSession)时,该 span 还会作为子代理
|
||||
// 泳道根折进那个会话的实时图,使分支渲染在派发它的会话图内,而不是自成一张图
|
||||
// (design/19 §2)。它返回携带该 span 的 ctx 与 span 自身(tracer 为 nil 时 span 为
|
||||
// nil)。
|
||||
// 子代理被归属到某张 flow 图(`SubAgent.flowOwner`,即 `{会话}[#{路}]`)时,该 span
|
||||
// 还会作为子代理泳道根折进那张图,使分支渲染在派发它的**那一路**图内,而不是自成
|
||||
// 一张图、也不会错落进另一路的图(design/19 §2)。它返回携带该 span 的 ctx 与 span
|
||||
// 自身(tracer 为 nil 时 span 为 nil)。
|
||||
func (s *Scheduler) subSpan(ctx context.Context, sub *SubAgent) (context.Context, *trace.Span) {
|
||||
if s.tracer == nil {
|
||||
return ctx, nil
|
||||
}
|
||||
sctx, span := s.tracer.StartSpan(ctx, "subagent."+sub.ID, trace.SpanKindSubagent)
|
||||
if sess := sub.ParentSession; sess != "" && s.flow != nil {
|
||||
_ = span.SetProp("session", sess)
|
||||
s.flow.SpanStarted(sess, span)
|
||||
if owner := sub.flowOwner(); owner != "" && s.flow != nil {
|
||||
_ = span.SetProp("session", owner)
|
||||
s.flow.SpanStarted(owner, span)
|
||||
}
|
||||
// 目标角色随分支节点传播,使流程图面板展示子代理以哪个角色作答
|
||||
// (design/22 §8-2 可观测性),而不只是子代理 id。
|
||||
@@ -427,13 +429,16 @@ func (s *Scheduler) spanErr(sub *SubAgent, span *trace.Span, err error) {
|
||||
s.foldSpanFinish(sub, span)
|
||||
}
|
||||
|
||||
// foldSpanFinish 把一个刚关闭的子代理 span 折进其父会话的流程图,把泳道根翻转为终态。
|
||||
// 该 span 已把父会话带在 "session" 属性上,因此发布器知道哪张图拥有它。
|
||||
// foldSpanFinish 把一个刚关闭的子代理 span 折进其所属的流程图,把泳道根翻转为终态。
|
||||
// 该 span 已把归属桶带在 "session" 属性上,因此发布器知道哪张图拥有它
|
||||
// (真并行时就是派发它的那一路的图,见 SubAgent.flowOwner)。
|
||||
func (s *Scheduler) foldSpanFinish(sub *SubAgent, span *trace.Span) {
|
||||
if span == nil || s.flow == nil || sub.ParentSession == "" {
|
||||
if span == nil || s.flow == nil || sub == nil {
|
||||
return
|
||||
}
|
||||
s.flow.SpanFinished(sub.ParentSession, span)
|
||||
if owner := sub.flowOwner(); owner != "" {
|
||||
s.flow.SpanFinished(owner, span)
|
||||
}
|
||||
}
|
||||
|
||||
// publish 经总线广播一条进度事件。未配置 comm 探针时是空操作。发布使用后台上下文,
|
||||
|
||||
@@ -22,7 +22,8 @@ const (
|
||||
// 「派发标记」:与泳道根并列而不是它的父级 —— 若把它当作子代理 span 的父级,
|
||||
// 泳道链会多出一节(图构建器把非 subagent 祖先一路穿透解析泳道),既有泳道
|
||||
// 契约就被改写了。span 只带非敏感属性(被派发的子代理 id、目标角色 id),
|
||||
// 声明了归属会话时同时发布一个「运行中」占位节点。未配置 tracer 时返回 nil。
|
||||
// 声明了归属时同时发布一个「运行中」占位节点(真并行时落进派发它的那一路的图)。
|
||||
// 未配置 tracer 时返回 nil。
|
||||
func (s *Scheduler) orchSpan(ctx context.Context, sub *SubAgent) *trace.Span {
|
||||
if s.tracer == nil || sub == nil {
|
||||
return nil
|
||||
@@ -34,15 +35,15 @@ func (s *Scheduler) orchSpan(ctx context.Context, sub *SubAgent) *trace.Span {
|
||||
if sub.PersonaID != "" {
|
||||
_ = span.SetProp("persona", sub.PersonaID)
|
||||
}
|
||||
if sess := sub.ParentSession; sess != "" && s.flow != nil {
|
||||
_ = span.SetProp("session", sess)
|
||||
s.flow.SpanStarted(sess, span)
|
||||
if owner := sub.flowOwner(); owner != "" && s.flow != nil {
|
||||
_ = span.SetProp("session", owner)
|
||||
s.flow.SpanStarted(owner, span)
|
||||
}
|
||||
return span
|
||||
}
|
||||
|
||||
// finishOrchSpan 收尾一次派发:记录耗时(失败另记原因)与一条脱敏事件,
|
||||
// 失败时终态为 error,然后把已关闭的 span 折回所属会话的图。
|
||||
// 失败时终态为 error,然后把已关闭的 span 折回所属那一路的图。
|
||||
func (s *Scheduler) finishOrchSpan(sub *SubAgent, span *trace.Span, err error) {
|
||||
if span == nil {
|
||||
return
|
||||
@@ -58,8 +59,10 @@ func (s *Scheduler) finishOrchSpan(sub *SubAgent, span *trace.Span, err error) {
|
||||
status = trace.SpanStatusError
|
||||
}
|
||||
span.Finish(status)
|
||||
if s.flow == nil || sub == nil || sub.ParentSession == "" {
|
||||
if s.flow == nil || sub == nil {
|
||||
return
|
||||
}
|
||||
s.flow.SpanFinished(sub.ParentSession, span)
|
||||
if owner := sub.flowOwner(); owner != "" {
|
||||
s.flow.SpanFinished(owner, span)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,10 +153,17 @@ func (t *SpawnTool) Execute(ctx context.Context, args any) (any, error) {
|
||||
if t.sched == nil {
|
||||
return nil, fmt.Errorf("%s: %w", SpawnToolName, ErrNoScheduler)
|
||||
}
|
||||
specs, err := spawnSpecs(m, loop.FlowOwnerFromContext(ctx), t.timeout)
|
||||
specs, err := spawnSpecs(m, loop.FlowSessionFromContext(ctx), t.timeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", SpawnToolName, err)
|
||||
}
|
||||
// flow 归属桶(`{会话}[#{路}]`)另取一次:ParentSession 必须是**真会话 id**
|
||||
// (后台批次的 `subagent_result` 帧按它归属到父会话),而图归属要精确到**哪一路**,
|
||||
// 否则真并行时两路子代理会混进同一张图(design/15 §2.5.10-1)。
|
||||
owner := loop.FlowOwnerFromContext(ctx)
|
||||
for i := range specs {
|
||||
specs[i].FlowOwner = owner
|
||||
}
|
||||
// 在任何东西启动之前就拒绝未知的目标角色:批次必须是全有或全无,否则已经派发出的
|
||||
// 子代理会继续运行,而它们的结果永远不上报给模型(design/22 §8-2: 未知角色明确报错,
|
||||
// 不静默回落到默认角色).
|
||||
|
||||
@@ -58,9 +58,17 @@ type AgentSpec struct {
|
||||
AttachInfo string
|
||||
// ParentSession 是拥有该分支的会话,其实时流程图将承载子代理的 span 及其内部
|
||||
// 步骤,使它们渲染成派发会话图的一条泳道,而不是自成一张图(design/19 §2)。
|
||||
// 派发工具从 loop.FlowOwnerFromContext 取得它。留空则关闭流图归属;它绝不影响
|
||||
// 子代理在哪个会话里运行,因此其 checkpoint、记忆与流隔离都不变。
|
||||
// 派发工具从 loop.FlowSessionFromContext 取得它(**不含路**:后台批次的
|
||||
// `subagent_result` 帧按它归属到父会话,因此它必须是真会话 id)。
|
||||
// 留空则关闭流图归属;它绝不影响子代理在哪个会话里运行,因此其 checkpoint、
|
||||
// 记忆与流隔离都不变。
|
||||
ParentSession string
|
||||
// FlowOwner 是子代理 span 落进**哪一张** flow 图的归属桶
|
||||
// (`{会话}[#{路}]`,见 loop.FlowOwnerFromContext)。真并行时同一会话有多条路,
|
||||
// 派发它的那一路有自己的一张图,子代理的泳道必须落进那一路 —— 否则两路的
|
||||
// 子代理节点会互相长在对方的图上(design/15 §2.5.10-1)。派发工具填它;
|
||||
// 留空时回落到 ParentSession(会话主干那张,即单路与旧调用方的既有行为)。
|
||||
FlowOwner string
|
||||
// Data 承载供调用方使用的可选结构化载荷。
|
||||
Data any
|
||||
// ToolWhitelist 限定该子代理可执行的工具集合(design/05 §7 裁决 02.4.1):
|
||||
|
||||
@@ -11,11 +11,11 @@ import (
|
||||
)
|
||||
|
||||
// SubAgent 是单个已派发的子代理实例。身份字段(ID、Goal、RoleID、PersonaID、Mode、
|
||||
// AttachInfo、ParentSession、Data、ToolWhitelist、Context、WorkingMemory)在 Spawn 时
|
||||
// 一次设定,此后永不改动。生命周期字段(Status、Summary、Result、Err)由子代理的后台
|
||||
// goroutine 写入,并经下方同步访问器读取,因此在子代理结束前读取它们是安全的。当且
|
||||
// 仅当 Done 报告 true 时子代理处于终结态;Join 在全部子代理终结后返回,Wait 在指名的
|
||||
// id 全部终结后返回。
|
||||
// AttachInfo、ParentSession、FlowOwner、Data、ToolWhitelist、Context、WorkingMemory)
|
||||
// 在 Spawn 时一次设定,此后永不改动。生命周期字段(Status、Summary、Result、Err)由
|
||||
// 子代理的后台 goroutine 写入,并经下方同步访问器读取,因此在子代理结束前读取它们
|
||||
// 是安全的。当且仅当 Done 报告 true 时子代理处于终结态;Join 在全部子代理终结后
|
||||
// 返回,Wait 在指名的 id 全部终结后返回。
|
||||
//
|
||||
// SubAgent 由 (*Scheduler).Spawn 创建;不应直接构造。
|
||||
type SubAgent struct {
|
||||
@@ -35,6 +35,12 @@ type SubAgent struct {
|
||||
AttachInfo string
|
||||
// ParentSession 是拥有本子代理分支的流程图所属会话(该分支不被可视化时为空)。
|
||||
ParentSession string
|
||||
// FlowOwner 是本子代理 span 落进**哪一张** flow 图的归属桶
|
||||
// (`{会话}[#{路}]`,见 loop.FlowOwnerFromContext):真并行时派发它的那一路
|
||||
// 有自己的图,本子代理必须落进那一路,而不是会话主干那张 —— 否则两路的
|
||||
// 子代理节点会互相长在对方的图上(design/15 §2.5.10-1)。留空时回落到
|
||||
// ParentSession(=会话主干那张,单路/旧调用方的既有行为)。
|
||||
FlowOwner string
|
||||
// Data 是派发时提供的可选结构化载荷。
|
||||
Data any
|
||||
// ToolWhitelist 是该子代理的工具白名单(design/05 §7 裁决 02.4.1):
|
||||
@@ -117,6 +123,20 @@ func (s *SubAgent) FinishedAt() time.Time {
|
||||
// Done 报告子代理是否已进入终结态。
|
||||
func (s *SubAgent) Done() bool { return s.Status().Terminal() }
|
||||
|
||||
// flowOwner 返回本子代理 span 该落进哪一张 flow 图的归属桶
|
||||
// (`{会话}[#{路}]`):显式声明了 FlowOwner 时用它(真并行路派发的子代理因此落进
|
||||
// **那一路**的图),否则回落到 ParentSession(会话主干那张图,即单路与旧调用方
|
||||
// 的既有行为)。两者都为空时返回空串 —— 调用方据此抑制发布(不被可视化的分支)。
|
||||
func (s *SubAgent) flowOwner() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
if s.FlowOwner != "" {
|
||||
return s.FlowOwner
|
||||
}
|
||||
return s.ParentSession
|
||||
}
|
||||
|
||||
// waitDone 返回子代理进入终结态时被关闭的 channel。它是 (*Scheduler).Wait 按 id 阻塞
|
||||
// 时等待的对象,无需轮询。
|
||||
func (s *SubAgent) waitDone() <-chan struct{} { return s.finishedCh }
|
||||
|
||||
Reference in new issue
Block a user