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

218 lines
9.7 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 gateway
import (
"context"
"net/http"
"strings"
"time"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// mainSessionID 是未指定 `?sessionId=` 时的默认会话 —— Web 主会话
// (`internal/runtime/history.MainSessionID`)。它与网关其余默认会话
// 口径同源(见 ws_hub_turn.go 的 streamChat),因此不新增第二个「默认会话」
// 常量。
const mainSessionID = history.MainSessionID
// flowUnavailableCode 是图源未接线时的机器可读错误码。它**不**回
// graph:null:那会把「图源没接线」伪装成「这一轮确实没有图」,与
// usage_unavailable / channel_events_unavailable 同一口径。
const flowUnavailableCode = "flow_unavailable"
// 图上那张图**是哪来的**(契约 v0.73 §4.11):
// - live = 内存里**最近一轮**的实时图(`loop.FlowPublisher.LastGraph`);
// - persisted = 从本地库读回的**已落盘轮次**(历史回放);
// - none = 没有图(诚实空态)。
//
// 前端据此渲染来源说明条(v0.73 起也可经它的 `source` 字段直接分辨,
// 不再只靠对象引用)。
const (
flowSourceLive = "live"
flowSourcePersisted = "persisted"
flowSourceNone = "none"
)
// flowTurnDTO 是「该会话已落盘轮次」的一条摘要(契约 v0.73 的 `turns[]`)。
// 它是给面板选历史轮用的:`turnId` 回带给本端点当 `?turnId=`,`createdAt` 供排序/展示。
type flowTurnDTO struct {
TurnID string `json:"turnId"`
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.77 起新增 `lanes`
// 一键**,均为纯增量)。
//
// Graph 为 nil 表示「该轮确实没有图」——这是一个**诚实的空态**,不是错误:
// 客户端必须据此渲染空态,绝不允许补画任何节点或假图(§4.11(b))。
// 该键**恒在**(`null` 而不是缺席),使客户端不必区分「没有这个键」与
// 「这个键是空」。
//
// Turns **恒为非 nil 切片**(没有轮次时是 `[]`),使客户端不必判空。Lanes 同此口径。
type adminFlowGraphResponse struct {
SessionID string `json:"sessionId"`
// TurnID 是**实际返回的那一轮**的标识(`graph.id`);没有图时为空串。
TurnID string `json:"turnId"`
// Source ∈ live / persisted / none,见上方常量。
Source string `json:"source"`
// Graph 是**该轮**的图快照,与 `graph_update` 帧的 data 逐字同形
// (契约 §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"`
}
// adminFlowGraph 是 `GET /api/v1/admin/flow/graph` 的处理器(契约 v0.71 起,
// v0.73 增量;design/19 §4 的 P3「多轮历史回放」)。
//
// 它回答的是主人报的两个洞:① `graph_update` **只走实时帧、从不重放**(v0.71 修);
// ② 图只活在进程内存、刷新/重启/翻更早轮次就没了 ⇒ 现在每轮收尾**落盘**,本端点可按
// `turnId` 取历史轮、并给出可选轮次列表(v0.73)。三条硬约束:
//
// 1. **只读**:经 `FlowGraphSource` / `FlowGraphStore` 拿快照,网关不发布、不改图、
// 不动任何会话、也不落盘;
// 2. **不画假图**:没有该轮 ⇒ `graph:null`;图源未接线(内存 + 落盘都没接)⇒
// `500 flow_unavailable`(**不是** graph:null,见 flowUnavailableCode);
// 3. **最近一轮优先内存,miss 才落盘**:省略 `turnId` 时先查内存 `LastGraph`(正在跑/
// 刚跑完的那一轮,最新),取不到才回退落盘最新 —— 「最新一轮永远是最新的」。
//
// 角色门:`writeAuth`(owner|admin),与 design/19 §3「flow 可视化仅 owner/admin」
// 一致;它与同一张图的 WS 下行帧(`graph_update` 落 frameReachesAll 的 fail-closed
// 默认档 = 只发 owner+admin)是同一档,因此**不存在**「帧看不到、REST 看得到」的
// 旁路。本端点只读、不审计。
func (s *Server) adminFlowGraph(w http.ResponseWriter, r *http.Request) {
sessionID := strings.TrimSpace(r.URL.Query().Get("sessionId"))
if sessionID == "" {
sessionID = mainSessionID
}
turnID := strings.TrimSpace(r.URL.Query().Get("turnId"))
if s.flow == nil && s.flowGraphs == nil {
s.log.Warn("flow graph source not wired", "session", sessionID)
writeError(w, apperr.Wrap(apperr.ErrInternal, flowUnavailableCode, "flow graph source is not wired"))
return
}
ctx := r.Context()
graph, source, err := s.resolveFlowGraph(ctx, sessionID, turnID)
if err != nil {
// 原始错误(可能带 SQL/路径)只进日志;响应只给稳定 code + 中文说明。
s.log.Warn("flow graph read failed", "session", sessionID, "turn", turnID, "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, flowUnavailableCode, "读取链路图失败"))
return
}
turns, err := s.flowTurns(ctx, sessionID)
if err != nil {
s.log.Warn("flow turns read failed", "session", sessionID, "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, flowUnavailableCode, "读取轮次列表失败"))
return
}
// Graph 恒在响应里(没有图时是 `null`),见 adminFlowGraphResponse 的注释。
var graphAny any
resolvedTurn := ""
if graph != nil {
graphAny = graph
resolvedTurn = graph.ID
}
writeJSON(w, http.StatusOK, adminFlowGraphResponse{
SessionID: sessionID,
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))。
//
// 判定顺序(三条都必要):
// - 内存里有图,且(省略 turnId **或** turnId 正是内存那一轮)⇒ 回内存图
// (`source:"live"`)—— 最新一轮永远最新;
// - 否则若给了 turnId ⇒ 精确读落盘那一轮(`source:"persisted"`);
// - 否则(省略 turnId、内存 miss)⇒ 读落盘**最新**一轮(`source:"persisted"`)。
//
// 落盘查询失败**上抛**(调用方回 500):把「读库失败」伪装成「没有这一轮」是
// 项目明令禁止的那类静默降级。该轮确实缺席返回 (nil, "none", nil)。
func (s *Server) resolveFlowGraph(ctx context.Context, sessionID, turnID string) (*trace.FlowGraph, string, error) {
if s.flow != nil {
if snap := s.flow.LastGraph(sessionID); snap != nil && (turnID == "" || snap.ID == turnID) {
return snap, flowSourceLive, nil
}
}
if s.flowGraphs == nil {
return nil, flowSourceNone, nil
}
var (
graph *trace.FlowGraph
err error
)
if turnID == "" {
graph, err = s.flowGraphs.Latest(ctx, sessionID)
} else {
graph, err = s.flowGraphs.Graph(ctx, sessionID, turnID)
}
if err != nil {
return nil, flowSourceNone, err
}
if graph == nil {
return nil, flowSourceNone, nil
}
return graph, flowSourcePersisted, nil
}
// flowTurns 取该会话已落盘轮次的摘要(最新在前)。落盘未接线时回**空切片**
// (不是错误):端点在「只有内存」的形态下照常可用(v0.71 口径),只是没有历史轮可选。
func (s *Server) flowTurns(ctx context.Context, sessionID string) ([]flowTurnDTO, error) {
out := make([]flowTurnDTO, 0)
if s.flowGraphs == nil {
return out, nil
}
turns, err := s.flowGraphs.Turns(ctx, sessionID, 0)
if err != nil {
return nil, err
}
for _, t := range turns {
out = append(out, flowTurnDTO{TurnID: t.TurnID, CreatedAt: t.CreatedAt.UTC().Format(time.RFC3339)})
}
return out, nil
}