fix(flow): graph_update 按路分桶——修真并行两路的图互相覆盖(FV2)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

【取证(症状成立,且比登记描述更糟)】两路真并行时两路最终落盘**同一个 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:
AskaEth committed 2026-10-06 08:16:03 +08:00
1 parent c9144c1096
commit 89434eadef
30 files changed
+2039 -148

No files matched your search

+18 -13
View File
@@ -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 探针时是空操作。发布使用后台上下文,