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

+85 -19
View File
@@ -1,6 +1,40 @@
package loop
import "context"
import (
"context"
"strings"
)
// flowOwnerSep 是 flow 图**归属桶**(`{会话}#{路}`)的分隔符。它与
// design/15 §2.5.9 的 checkpoint 归属键(`{sessionID}#{turnID}`)以及
// trace 的图 id 拼接口径(`{会话}-{轮次}#{路}`)取同一个字符:**一条路**
// 在流程图、checkpoint 与落盘行三处因此是同一个标识。
//
// 之所以能在同一个字符串里编码「会话 + 路」:会话 id 的取值族是
// `web` / `web:<uuid>` / `sess-backthink`(`cmd/cyrene` 的 flowSessionAllowed),
// 权威 turnId 是 `turn-<hex>`(`gateway.newTurnID`),两者都不含 `#`。
const flowOwnerSep = "#"
// FlowOwnerKey 拼出「会话[#路]」的 flow 归属桶;lane 为空时就是会话本身
// (单路 / 未声明并行的运行,逐字等于引入本特性之前的会话 id)。
//
// 导出它是因为**归属桶的串形状只有一个权威**:发布器的建图出口、子代理 span 的
// 归属、以及跨包的验收测试都按它拼串,任何一处自己拼 `#` 都会在换分隔符时静默走偏。
func FlowOwnerKey(sessionID, lane string) string {
if lane == "" {
return sessionID
}
return sessionID + flowOwnerSep + lane
}
// splitFlowOwner 把归属桶拆回(会话, 路)。没有 `#` 时整串就是会话、路为空 ——
// 这正是单路运行与**旧调用方**传给发布器的形态,因此拆分是向后兼容的。
func splitFlowOwner(owner string) (string, string) {
if i := strings.Index(owner, flowOwnerSep); i >= 0 {
return owner[:i], owner[i+1:]
}
return owner, ""
}
// flowBranchKey 是标记某个 Run 为另一会话 flow 图分支的 context key。
// 它由 orchestration 层(经 WithFlowBranch)在驱动
@@ -8,24 +42,28 @@ import "context"
type flowBranchKey struct{}
// WithFlowBranch 返回 ctx,把当前 Run 标记为分支:它的 span 并入
// parentSession 的实时 flow 图,且该 Run 自身既不开始也不结束
// parent 的实时 flow 图,且该 Run 自身既不开始也不结束
// 轮次 —— 由所属会话的轮次负责。orchestration 层正是靠它把
// 子代理的工作归属到派生它的会话,使并行的
// 子代理渲染为同一张图的泳道,而不是用各自的图
// 替换前端唯一的 flowGraph(design/19 §2)。
//
// 空的 parentSession 仍把该 Run 标记为分支:它不拥有轮次、
// parent 是**归属桶**(`{会话}#{路}`,见 FlowOwnerKey):并行路里派发的子代理必须
// 并进**那一路**的图,而不是会话主干那张 —— 否则两路的子代理节点会混进同一张图,
// 正是本切片要治的「两路互相覆盖」(design/15 §2.5.10-1)。单路时它逐字等于会话 id。
//
// 空的 parent 仍把该 Run 标记为分支:它不拥有轮次、
// 不发布任何东西。这正是无人归属到会话的后台子代理的
// 状态,它不得悄悄变成属于自己的一张图。nil ctx
// 原样返回。
func WithFlowBranch(ctx context.Context, parentSession string) context.Context {
func WithFlowBranch(ctx context.Context, parent string) context.Context {
if ctx == nil {
return ctx
}
return context.WithValue(ctx, flowBranchKey{}, parentSession)
return context.WithValue(ctx, flowBranchKey{}, parent)
}
// flowBranch 报告 WithFlowBranch 记录的父会话以及
// flowBranch 报告 WithFlowBranch 记录的父归属桶以及
// 该 Run 是否本就是分支。非分支的 Run 拥有自己的轮次并在
// 自己的 session id 下发布。
func flowBranch(ctx context.Context) (string, bool) {
@@ -40,27 +78,55 @@ func flowBranch(ctx context.Context) (string, bool) {
return s, true
}
// flowOwner 返回应当接收该 Run span 的 flow 图所属会话:
// flowOwner 返回应当接收该 Run span 的 flow 归属桶:
// 当该 Run 是子代理分支时取分支父级(分支未归属时为空,
// 此时抑制发布),否则取该 Run 自身的 sessionID。
// 此时抑制发布),否则取该 Run 自身的 session 加上它所属的**路**
// (真并行路的标识由网关经 WithParallelLeg 盖上;单路时为会话本身)。
func flowOwner(ctx context.Context, sessionID string) string {
if parent, isBranch := flowBranch(ctx); isBranch {
return parent
}
return sessionID
return FlowOwnerKey(sessionID, ParallelLegFrom(ctx))
}
// FlowOwnerFromContext 返回拥有从 ctx 启动的工作的 flow 图所属会话:
// 当 ctx 运行于子代理分支内时取分支父级,否则
// 取外层 Run 被驱动的 session id(两者都未设置时为空)。
// FlowOwnerFromContext 返回拥有从 ctx 启动的工作的 flow 图**归属桶**
// (`{会话}#{路}`,见 FlowOwnerKey):当 ctx 运行于子代理分支内时取分支父级,
// 否则取外层 Run 被驱动的 session id 加上它所属的路(两者都未设置时为空)。
//
// 派发后台工作的工具 —— 子代理派生工具 —— 把它
// 作为父会话传入,使在另一个子代理内部派生的子代理
// 保持在同一个顶层图的归属之下,而不是归属到一个不拥有
// 任何图的 id。
// 派发后台工作的工具 —— 子代理派生工具 —— 把它作为父归属桶传入,使在另一个
// 子代理内部派生的子代理保持在同一个顶层图的归属之下,而不是归属到一个不拥有
// 任何图的 id;真并行路里派发的子代理也因此落进**那一路**的图。
//
// ⚠️ 它返回的是**归属桶**而不是会话 id。要把子代理的归属会话交给总线载荷
// (`subagent_result` 的 sessionId 必须是真会话 id)时必须用
// FlowSessionFromContext 取会话那一半。
func FlowOwnerFromContext(ctx context.Context) string {
if parent, isBranch := flowBranch(ctx); isBranch {
return parent
if owner, isBranch := flowBranch(ctx); isBranch {
return owner
}
return SessionFromContext(ctx)
session := SessionFromContext(ctx)
if session == "" {
// 没有会话就没有归属:绝不回一个 `#路` 形态的串(那既查不到图,也会让
// 调用方误以为有归属)。分支与子代理因此保持「不发布」的既有语义。
return ""
}
return FlowOwnerKey(session, FlowLaneFromContext(ctx))
}
// FlowSessionFromContext 返回从 ctx 启动的工作的**归属会话 id**(不含路):
// 真并行时同一会话的每一路都是同一个会话 id,总线载荷与日志要的就是它。
func FlowSessionFromContext(ctx context.Context) string {
session, _ := splitFlowOwner(FlowOwnerFromContext(ctx))
return session
}
// FlowLaneFromContext 返回从 ctx 启动的工作所属的**路**标识(design/15 §2.5.9
// 的 turnId);单路 / 未声明并行时为空。分支运行取分支归属桶里的那一半(子代理
// 因此继承派生它的那一路),非分支运行取本 Run 的并发路。
func FlowLaneFromContext(ctx context.Context) string {
if owner, isBranch := flowBranch(ctx); isBranch {
_, lane := splitFlowOwner(owner)
return lane
}
return ParallelLegFrom(ctx)
}