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) 提交里(同一个工作时段内)。
This commit is contained in:
1 parent
c9144c1096
commit
89434eadef
30 files changed
+2039
-148
No files matched your search
@@ -61,12 +61,22 @@ type FlowEdge struct {
|
||||
|
||||
// FlowGraph is a live, single-round flow graph published as ws.graph_update.
|
||||
type FlowGraph struct {
|
||||
ID string `json:"id"`
|
||||
Nodes []FlowNode `json:"nodes"`
|
||||
Edges []FlowEdge `json:"edges"`
|
||||
SessionID string `json:"sessionId,omitempty"`
|
||||
StartedAt time.Time `json:"startedAt,omitempty"`
|
||||
EndedAt time.Time `json:"endedAt,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Nodes []FlowNode `json:"nodes"`
|
||||
Edges []FlowEdge `json:"edges"`
|
||||
// SessionID 是拥有这张图的会话(帧壳与落盘行的归属坐标)。
|
||||
SessionID string `json:"sessionId,omitempty"`
|
||||
// TurnID 是这张图所属**路**的权威回合标识(design/15 §2.5.9):真并行时
|
||||
// 同一会话有多条路,每条路各有自己的图与本键。**缺席**语义:这条路没有
|
||||
// 权威标识(单路的历史帧、未接编排器的调用方、旧后端)——客户端据此把图
|
||||
// 归到会话那一张(兼容口径),绝不臆造一个 id。
|
||||
TurnID string `json:"turnId,omitempty"`
|
||||
// Round 是这张图所属的**会话轮次号**(每会话单调递增;同一轮的各条路共享它)。
|
||||
// 客户端据此只保留最新一轮的泳道,避免旧轮的路留在页面上。
|
||||
// **缺席**(0)语义:旧后端/未设置 —— 客户端按「每次帧都算同一轮」的保守口径处理。
|
||||
Round int `json:"round,omitempty"`
|
||||
StartedAt time.Time `json:"startedAt,omitempty"`
|
||||
EndedAt time.Time `json:"endedAt,omitempty"`
|
||||
}
|
||||
|
||||
// NodeSpec is the input to RunGraph.Upsert: the full description of a node plus
|
||||
@@ -87,8 +97,16 @@ type NodeSpec struct {
|
||||
// structure: it knows nothing about the bus, sessions or the loop, so it is
|
||||
// unit-testable in isolation. A caller serializes access (see loop.FlowPublisher).
|
||||
type RunGraph struct {
|
||||
session string
|
||||
round int
|
||||
session string
|
||||
round int
|
||||
// seq 是本图的**会话级建图序号**(调用方每次建图递增、永不复用)。图 id 的第二
|
||||
// 分量取它而不是轮次号:真并行时同一轮里的第二条路必须也拿到全新的 id,否则两路
|
||||
// 撞同一个 `flow_graphs.turn_id`(唯一键),后收尾的那一路把前一路整行顶掉。
|
||||
// 未经 SetSeq 建图的调用方(图构建器单测)保留 `{会话}-{轮次}` 的老形状。
|
||||
seq int
|
||||
// turnID 是本图所属**路**的权威回合标识(design/15 §2.5.9)。空串表示这条路
|
||||
// 没有权威标识(单路 / 未接编排器 / 旧调用方),此时图上该键缺席。
|
||||
turnID string
|
||||
nodes map[string]*FlowNode
|
||||
order []string
|
||||
edges map[string]bool
|
||||
@@ -118,7 +136,8 @@ func NewRunGraph(session string) *RunGraph {
|
||||
}
|
||||
|
||||
// Start marks the round began at now and resets the node/edge sets so a reused
|
||||
// graph starts fresh for a new round.
|
||||
// graph starts fresh for a new round. 路口标识不在重置之列:它是**本次建图**的
|
||||
// 身份,由 SetTurnID 在 Start 之后立刻盖上(见 loop.FlowPublisher)。
|
||||
func (g *RunGraph) Start(round int, now time.Time) {
|
||||
g.round = round
|
||||
g.started = now
|
||||
@@ -131,6 +150,15 @@ func (g *RunGraph) Start(round int, now time.Time) {
|
||||
g.laneLast = make(map[string]string)
|
||||
}
|
||||
|
||||
// SetTurnID 盖上这张图所属**路**的权威回合标识(design/15 §2.5.9)。空串是
|
||||
// 合法入参(单路 / 未接编排器),此时快照里该键缺席(omitempty),客户端回落
|
||||
// 到「按会话归一张图」的兼容口径。
|
||||
func (g *RunGraph) SetTurnID(turnID string) { g.turnID = turnID }
|
||||
|
||||
// SetSeq 盖上这张图的**会话级建图序号**(见 RunGraph.seq)。未设置(0)时图 id
|
||||
// 退回 `{会话}-{轮次}` 的老形状,因此只调 Start 的既有调用方/单测逐字不变。
|
||||
func (g *RunGraph) SetSeq(seq int) { g.seq = seq }
|
||||
|
||||
// SeedInput creates the "input" node for the round. It returns the node id so a
|
||||
// caller can reference it. The label is redacted (design/19 §3), so a message that
|
||||
// happens to carry a credential cannot publish one.
|
||||
@@ -160,22 +188,48 @@ func (g *RunGraph) SeedOutput(text string) string {
|
||||
return "output"
|
||||
}
|
||||
|
||||
// Snapshot returns a copy of the graph safe to encode and publish.
|
||||
// laneIDSep 是图 id 里「会话-轮次」与「路标识」之间的分隔符。它与 `loop` 的
|
||||
// flow 归属串(`{会话}#{路}`)以及 design/15 §2.5.9 的 checkpoint 归属键
|
||||
// (`{sessionID}#{turnID}`)取同一个字符:**一条路**在流程图、checkpoint 与
|
||||
// 落盘行三处因此是同一个拼接口径(loop 侧的同名常量见 loop/flow_branch.go)。
|
||||
const laneIDSep = "#"
|
||||
|
||||
// Snapshot returns a copy of the graph safe to encode and publish. 图 id 是
|
||||
// `{会话}-{轮次}`;这张图属于一条**路**时追加 `#{路标识}` —— 真并行下同一会话
|
||||
// 同一轮的两路必须各有各的 id,否则落盘(`flow_graphs` 的 `(session_id, turn_id)`
|
||||
// 唯一键)会把其中一路整行顶掉、客户端也分不出两张图(design/15 §2.5.10-1)。
|
||||
func (g *RunGraph) Snapshot() *FlowGraph {
|
||||
nodes := make([]FlowNode, 0, len(g.order))
|
||||
for _, id := range g.order {
|
||||
nodes = append(nodes, *g.nodes[id])
|
||||
}
|
||||
return &FlowGraph{
|
||||
ID: fmt.Sprintf("%s-%d", g.session, g.round),
|
||||
ID: g.graphID(),
|
||||
Nodes: nodes,
|
||||
Edges: append([]FlowEdge(nil), g.edgeOrder...),
|
||||
SessionID: g.session,
|
||||
TurnID: g.turnID,
|
||||
Round: g.round,
|
||||
StartedAt: g.started,
|
||||
EndedAt: g.ended,
|
||||
}
|
||||
}
|
||||
|
||||
// graphID 拼出这张图的 id:`{会话}-{序号}[#{路标识}]`。序号取会话级建图序号
|
||||
// (未设置时退回轮次号,见 RunGraph.seq),因此**同一轮里的每一条路也各有各的 id**;
|
||||
// 路标识只在「这条路有权威标识」时追加。
|
||||
func (g *RunGraph) graphID() string {
|
||||
n := g.seq
|
||||
if n == 0 {
|
||||
n = g.round
|
||||
}
|
||||
base := fmt.Sprintf("%s-%d", g.session, n)
|
||||
if g.turnID == "" {
|
||||
return base
|
||||
}
|
||||
return base + laneIDSep + g.turnID
|
||||
}
|
||||
|
||||
// Clone 深拷贝一份图,供**锁外**的读者使用(契约 v0.71 的
|
||||
// `GET /api/v1/admin/flow/graph` 就近取材于 `loop.FlowPublisher.LastGraph`)。
|
||||
//
|
||||
@@ -195,6 +249,8 @@ func (g *FlowGraph) Clone() *FlowGraph {
|
||||
Nodes: make([]FlowNode, 0, len(g.Nodes)),
|
||||
Edges: append([]FlowEdge(nil), g.Edges...),
|
||||
SessionID: g.SessionID,
|
||||
TurnID: g.TurnID,
|
||||
Round: g.Round,
|
||||
StartedAt: g.StartedAt,
|
||||
EndedAt: g.EndedAt,
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user