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

+40 -2
View File
@@ -42,15 +42,27 @@ type flowTurnDTO struct {
CreatedAt string `json:"createdAt"`
}
// flowLaneDTO 是「最近一轮里的一条路」(契约 v0.77 的 `lanes[]`):`turnId` 是**那一路**
// 的权威回合标识(`graph.turnId`,design/15 §2.5.9),`graph` 是那一路的图快照
// (与 `graph_update` 帧的 data / 本响应的 `graph` 逐字同形)。
//
// 真并行时同一会话同一轮有多条路,`graph` 只给出最新的一条(向后兼容:老客户端读它
// 照旧可用),`lanes[]` 才是全貌 —— 并行子代理与并行路各自的图因此不再互相顶掉。
type flowLaneDTO struct {
TurnID string `json:"turnId"`
Graph any `json:"graph"`
}
// adminFlowGraphResponse 是 GET /api/v1/admin/flow/graph 的响应体
// (v0.71 起;**v0.73 起新增 `turnId`/`source`/`turns` 三键**,纯增量)。
// (v0.71 起;**v0.73 起新增 `turnId`/`source`/`turns` 三键**,**v0.77 起新增 `lanes`
// 一键**,均为纯增量)。
//
// Graph 为 nil 表示「该轮确实没有图」——这是一个**诚实的空态**,不是错误:
// 客户端必须据此渲染空态,绝不允许补画任何节点或假图(§4.11(b))。
// 该键**恒在**(`null` 而不是缺席),使客户端不必区分「没有这个键」与
// 「这个键是空」。
//
// Turns **恒为非 nil 切片**(没有轮次时是 `[]`),使客户端不必判空。
// Turns **恒为非 nil 切片**(没有轮次时是 `[]`),使客户端不必判空。Lanes 同此口径。
type adminFlowGraphResponse struct {
SessionID string `json:"sessionId"`
// TurnID 是**实际返回的那一轮**的标识(`graph.id`);没有图时为空串。
@@ -61,6 +73,11 @@ type adminFlowGraphResponse struct {
// (契约 §3.4.5 / §4.11(a))。字段以 `any` 承载,因为「没有图」(nil ⇒
// 线上 JSON `null`)与「有图」的区分就是本字段的语义。
Graph any `json:"graph"`
// Lanes 是**最近一轮每一路**的图(契约 v0.77,纯增量;真并行才有意义的全貌)。
// 它取内存里的实时图(`FlowGraphSource.LatestLanes`),因此与 `?turnId=` 无关 ——
// 翻历史轮时 `lanes` 仍报「当前这一轮的各路」,而 `graph` 才是你指定的那一轮。
// 图源未接线或该会话还没有图时是空数组(恒非 nil)。
Lanes []flowLaneDTO `json:"lanes"`
// Turns 是该会话**已落盘轮次**的摘要,最新在前(契约 v0.73)。
Turns []flowTurnDTO `json:"turns"`
}
@@ -120,10 +137,31 @@ func (s *Server) adminFlowGraph(w http.ResponseWriter, r *http.Request) {
TurnID: resolvedTurn,
Source: source,
Graph: graphAny,
Lanes: s.flowLanes(sessionID),
Turns: turns,
})
}
// flowLanes 取**最近一轮每一路**的图(契约 v0.77 的 `lanes[]`):真并行时同一会话
// 同一轮有多条路,只回 `graph` 一棵会把其余路从面板上抹掉(design/15 §2.5.10-1)。
//
// 它恒返回非 nil 切片(没有路时是 `[]`),使客户端不必判空。图源未接线(`s.flow` 为
// nil)时回空切片而不是错误:那时端点本就走「只有落盘」的形态(v0.73 口径),
// `lanes` 缺席语义 = 「没有实时路」。
func (s *Server) flowLanes(sessionID string) []flowLaneDTO {
out := make([]flowLaneDTO, 0)
if s.flow == nil {
return out
}
for _, g := range s.flow.LatestLanes(sessionID) {
if g == nil {
continue
}
out = append(out, flowLaneDTO{TurnID: g.TurnID, Graph: g})
}
return out
}
// resolveFlowGraph 解析「这一轮」的图:**先内存、miss 才落盘**(契约 v0.73 §4.11(b))。
//
// 判定顺序(三条都必要):