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

+25 -5
View File
@@ -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 }