Files
AskaEth 89434eadef
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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) 提交里(同一个工作时段内)。
2026-10-06 08:16:03 +08:00

133 lines
5.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package loop
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)在驱动
// 子代理 Run 之前设置。
type flowBranchKey struct{}
// WithFlowBranch 返回 ctx,把当前 Run 标记为分支:它的 span 并入
// parent 的实时 flow 图,且该 Run 自身既不开始也不结束
// 轮次 —— 由所属会话的轮次负责。orchestration 层正是靠它把
// 子代理的工作归属到派生它的会话,使并行的
// 子代理渲染为同一张图的泳道,而不是用各自的图
// 替换前端唯一的 flowGraph(design/19 §2)。
//
// parent 是**归属桶**(`{会话}#{路}`,见 FlowOwnerKey):并行路里派发的子代理必须
// 并进**那一路**的图,而不是会话主干那张 —— 否则两路的子代理节点会混进同一张图,
// 正是本切片要治的「两路互相覆盖」(design/15 §2.5.10-1)。单路时它逐字等于会话 id。
//
// 空的 parent 仍把该 Run 标记为分支:它不拥有轮次、
// 不发布任何东西。这正是无人归属到会话的后台子代理的
// 状态,它不得悄悄变成属于自己的一张图。nil ctx
// 原样返回。
func WithFlowBranch(ctx context.Context, parent string) context.Context {
if ctx == nil {
return ctx
}
return context.WithValue(ctx, flowBranchKey{}, parent)
}
// flowBranch 报告 WithFlowBranch 记录的父归属桶以及
// 该 Run 是否本就是分支。非分支的 Run 拥有自己的轮次并在
// 自己的 session id 下发布。
func flowBranch(ctx context.Context) (string, bool) {
if ctx == nil {
return "", false
}
v := ctx.Value(flowBranchKey{})
if v == nil {
return "", false
}
s, _ := v.(string)
return s, true
}
// flowOwner 返回应当接收该 Run span 的 flow 归属桶:
// 当该 Run 是子代理分支时取分支父级(分支未归属时为空,
// 此时抑制发布),否则取该 Run 自身的 session 加上它所属的**路**
// (真并行路的标识由网关经 WithParallelLeg 盖上;单路时为会话本身)。
func flowOwner(ctx context.Context, sessionID string) string {
if parent, isBranch := flowBranch(ctx); isBranch {
return parent
}
return FlowOwnerKey(sessionID, ParallelLegFrom(ctx))
}
// FlowOwnerFromContext 返回拥有从 ctx 启动的工作的 flow 图**归属桶**
// (`{会话}#{路}`,见 FlowOwnerKey):当 ctx 运行于子代理分支内时取分支父级,
// 否则取外层 Run 被驱动的 session id 加上它所属的路(两者都未设置时为空)。
//
// 派发后台工作的工具 —— 子代理派生工具 —— 把它作为父归属桶传入,使在另一个
// 子代理内部派生的子代理保持在同一个顶层图的归属之下,而不是归属到一个不拥有
// 任何图的 id;真并行路里派发的子代理也因此落进**那一路**的图。
//
// ⚠️ 它返回的是**归属桶**而不是会话 id。要把子代理的归属会话交给总线载荷
// (`subagent_result` 的 sessionId 必须是真会话 id)时必须用
// FlowSessionFromContext 取会话那一半。
func FlowOwnerFromContext(ctx context.Context) string {
if owner, isBranch := flowBranch(ctx); isBranch {
return owner
}
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)
}