【取证(症状成立,且比登记描述更糟)】两路真并行时两路最终落盘**同一个 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) 提交里(同一个工作时段内)。
133 lines
5.9 KiB
Go
133 lines
5.9 KiB
Go
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)
|
||
}
|