Files
Cyrene_v2/internal/runtime/loop/flow.go
T
AskaEth 2bf3b536dd fix(flow): 时间轴两处诚实性缺陷(X1)
① round 号跨重启被复用 ⇒ 时间轴把多个真实轮次并成一块(真机实测 web 的 r:1 挤了 4 路、
   startedAt 跨 13:52→21:05)。修法:发号前从落盘播种(MaxRound,json_extract + json_valid 挡坏行),
   每会话每进程只播一次、失败只 WARN 且不重试、并发首轮共享一次读;接口形状零变化
② truncated 一个字段兼两个意思 ⇒ 一页没拉完时前端会说「更早的轮次已过保留期」(假话)。
   新增 retentionBoundaryReached(= 保留期启用 且 已翻到底 且 现存最早一轮 round>1 ⇒ 确有轮次被清扫),
   truncated 语义一字未改;盲区如实登记(整段被清扫会说少、最早一轮落盘失败可能多说)
3 个变异自检(不落账/floor>0/去掉 hasMore 守卫)
2026-10-06 21:16:21 +08:00

478 lines
20 KiB
Go
Raw 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"
"log/slog"
"sort"
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// graphUpdateSubject 是 FlowPublisher 发布每轮实时
// flow 图所用的 bus subject。gateway hub 订阅 ws.> 并将其
// 作为 WS 事件类型 graph_update 转发(design/19 §1)。
const graphUpdateSubject = "ws.graph_update"
// flowLogSource 是发布器日志的 source 标签(落盘失败记 WARN;AGENTS §10 禁裸日志)。
const flowLogSource = "runtime/loop/flow"
// allowedSessions 是用于控制哪些会话可发布
// flow 图的谓词签名(主人的 web 会话),使 IM/陌生人/子代理轮次绝不
// 泄露进管理面板。谓词为 nil 时允许所有会话。
//
// 谓词收的是**会话 id**(不是归属桶):调用方先经 splitFlowOwner 拆掉路标识,
// 因此白名单永远只按会话判 —— 路的存在绝不放宽或收紧任何一档。
type allowedSessions func(sessionID string) bool
// RoundRecorder 把一条**已收尾轮次**的图落盘(design/19 §4 的 P3「多轮历史回放」;
// 切片 F2)。生产实现是 `internal/runtime/flowgraph` 的 `*SQLStore`。
//
// 语义约定(实现方必须遵守):**每轮一条**(只在 EndRound 里调用,绝不在每个 span
// 上调用),失败**上抛**给发布器(发布器记 WARN 后继续)—— 图是可视化资产,落盘失败
// 绝不允许影响会话收尾,也绝不改变实时帧。
//
// 「每轮一条」在真并行下的含义是**每一路一条**:两路各有自己的图 id(见
// trace.RunGraph 的 id 口径),因此 `flow_graphs` 的 `(session_id, turn_id)` 唯一键
// 不会被两路撞上(design/15 §2.5.10-1)。
//
// 白名单不在实现方:EndRound 对发布会话范围外的会话**直接返回**,因此落盘出口天然
// 只被白名单内的会话触发(复用 flowSessionAllowed,不存在第二份白名单)。
type RoundRecorder interface {
RecordRound(g *trace.FlowGraph) error
}
// FlowPublisherOption 配置 FlowPublisher 的可选行为(落盘出口 + 日志)。
type FlowPublisherOption func(*FlowPublisher)
// WithRoundRecorder 挂一个「每轮收尾时落一条」的落盘出口。传 nil 等价于不落盘。
func WithRoundRecorder(rec RoundRecorder) FlowPublisherOption {
return func(p *FlowPublisher) { p.record = rec }
}
// WithFlowLogger 给发布器一个 logger:落盘失败要**可见**(记 WARN),但绝不把图内容
// 写进日志(图里可能含用户输入文本,只记 session/turn 标识)。
func WithFlowLogger(log *slog.Logger) FlowPublisherOption {
return func(p *FlowPublisher) { p.log = log }
}
// FlowPublisher 从循环的 trace span 累积**按路**的 flow 图,
// 并在每个 span 开始/结束时发布 ws.graph_update,给前端
// 一张实时的 input→think→tool→output 图(design/19 §1)。它容忍 nil
// (nil 发布者为空操作)且并发安全;同一会话的图
// 在同一把互斥锁下串行化,因为并行的工具 span 会并发完成。
//
// 归属:**一个 Run 拥有一个路 bucket**。单路时 bucket 就是会话 id(行为与引入
// 「路」之前逐字相同);真并行时每一路各有自己的 bucket(`{会话}#{路}`,见
// FlowOwnerKey),因此两路的节点绝不互相长在对方的图上、也绝不互相重置
// (design/15 §2.5.10-1 登记的那条「graph_update 互相顶掉」)。声明自己是另一
// 会话分支的 Run(loop.WithFlowBranch)绝不调用 StartRound/EndRound,
// 而是把其 span 发布到所属路的 bucket 下,因此子代理渲染为
// 父图的一条泳道,而不是第二张图(design/19 §2)。
type FlowPublisher struct {
mu sync.Mutex
publish func(subject string, payload any)
allow allowedSessions
// graphs 是「归属桶 → 该桶当前这张图」。键见 FlowOwnerKey。
graphs map[string]*trace.RunGraph
// seq 是**每会话**的建图序号:每次 StartRound 加一,并写进图 id
// (`{会话}-{序号}[#{路}]`)。用序号而不是轮次号做 id 的第二个分量,是为了让
// 「同一轮里的第二条路」也拿到一个全新的 id —— 否则两路会撞同一个
// `flow_graphs.turn_id`(唯一键),后收尾的那一路把前一路整行顶掉。
//
// **跨重启**:本进程第一次给某会话发号之前,`ensureRoundSeeded` 会把它播到该会话
// **已落盘**的最大轮次号之上(见 flow_roundseed.go),因此重启不会重用轮次号。
seq map[string]int
// rounds 是每会话当前的**轮次号**(= 该轮第一条路的建图序号)。同一轮的各条路
// 共享它,客户端据此只保留最新一轮的泳道。它**跨重启单调**(同上:从落盘侧播种),
// 因此时间轴按它分组时不会把重启前后的两轮并成一块。
rounds map[string]int
// roundOf 是「归属桶 → 它属于哪一轮」。
roundOf map[string]int
// inflight 是每会话**在飞的桶**集合。新一轮的判据是「上一轮的路全部收尾」
// (集合为空),因此它是轮次号推进的唯一依据,也是内存不随路数无界增长的
// 依据(新一轮起时丢掉更早轮次的桶)。
inflight map[string]map[string]bool
// seedDone 是「该会话的播种动作已完成」的标记(成功或放弃,见 flow_roundseed.go);
// seeding 是正在播种的会话 → 完成信号,使同一会话的并发首轮等这一次播种。
seedDone map[string]bool
seeding map[string]chan struct{}
// record 是每轮收尾的落盘出口(design/19 §4 P3;切片 F2)。nil = 不落盘
// (保持 F1 之前的纯内存行为,且测试可只测发布)。
record RoundRecorder
// log 用于把落盘失败记成 WARN(带 source)。nil 时静默(测试/未接线)。
log *slog.Logger
}
// NewFlowPublisher 构建一个 FlowPublisher。publish 非 nil 时,会在
// 每次 span 状态迁移时以 graphUpdateSubject 与 *trace.FlowGraph
// 快照调用。allow 非 nil 时,控制哪些 session id 可发布(返回
// false 则整个会话被丢弃)。两者都可以为 nil(nil publish 为空操作)。
//
// 可变项经 Functional Options 传入(不改变既有两参调用):落盘出口(F2)与日志。
func NewFlowPublisher(publish func(subject string, payload any), allow func(sessionID string) bool, opts ...FlowPublisherOption) *FlowPublisher {
p := &FlowPublisher{
publish: publish,
allow: allow,
graphs: make(map[string]*trace.RunGraph),
seq: make(map[string]int),
rounds: make(map[string]int),
roundOf: make(map[string]int),
inflight: make(map[string]map[string]bool),
seedDone: make(map[string]bool),
seeding: make(map[string]chan struct{}),
}
for _, opt := range opts {
if opt != nil {
opt(p)
}
}
return p
}
// StartRound 为 owner 开始一张新图,用用户文本
// 播种一个 input 节点并发布初始帧。
//
// owner 是**归属桶**(`{会话}[#{路}]`,见 FlowOwnerKey):单路调用方传的就是会话
// id(引入本切片之前的所有调用点原样可用),真并行路由经 flowOwner 传
// `{会话}#{路}`。两条路各起各的图 ⇒ 后起的那一路绝不重置前一路的图。
//
// 本入口**没有 ctx 通道**,因此这一路的身份只能取**归属桶里的路标识**:真并行路的桶
// 正是网关按该路的 turnId 拼的,两者逐字同值;单路的桶里没有路标识 ⇒ 图上该键缺席。
// 要覆盖**全部编排回合**(普通单路回合与队首续跑路也算)请用 StartRoundWithTurnID,
// 它是 `Run` 走的入口。
func (p *FlowPublisher) StartRound(owner, inputText string) {
_, lane := splitFlowOwner(owner)
p.startRound(owner, lane, inputText)
}
// StartRoundWithTurnID 与 StartRound 同一件事,但这一路的**权威回合标识**取自 ctx
// (`TurnIDFrom`,design/15 §2.5.9):编排器给**每一路**都盖它 —— 普通单路回合、队首
// 续跑路、真并行路都有,因此它是「这张图属于哪一回合」的唯一正当来源,也是落盘
// `flow_graphs.lane_turn_id` 与消息面 `toolCalls[]`(按 `chat_messages.turn_id` 等值
// 回看)能对上的前提。
//
// ctx 上没有权威标识时(后台思考、未接编排器的调用方)**保持缺席**:快照上的 `turnId`
// 键与落盘行的 `lane_turn_id` 都是空 ⇒ 读侧查不到这一轮的图。那是契约允许的诚实空态,
// **绝不**拿会话 id、时间戳之类去臆造一个身份。
//
// 归属桶(owner)的语义一字未改:真并行两路仍各起各的图(桶是 `{会话}#{路}`),单路的
// 桶仍是会话 id。本方法只决定**这张图属于哪一回合**,不决定它是不是另一张图。
func (p *FlowPublisher) StartRoundWithTurnID(ctx context.Context, owner, inputText string) {
p.startRound(owner, TurnIDFrom(ctx), inputText)
}
// startRound 是两条入口的共用实现:turnID 是这一路的权威回合标识(空 = 缺席,绝不
// 臆造),owner 是归属桶。
//
// 轮次号只在**上一轮的路全部收尾**之后推进(inflight 为空),因此「同一条消息
// 引出的两路」共享同一个轮次号;新一轮开始时丢掉更早轮次的桶,内存因此不随路数
// 无界增长(图的历史在落盘里,见 flowgraph 包)。
//
// 发号之前先 `ensureRoundSeeded`:本进程第一次给该会话发号时从落盘侧播种,使轮次号
// **跨重启单调**(否则重启后从 1 重来,会把库里的旧轮次并进同一块,见 flow_roundseed.go)。
func (p *FlowPublisher) startRound(owner, turnID, inputText string) {
session, _ := splitFlowOwner(owner)
if session == "" || owner == "" || !p.allowed(session) {
return
}
p.ensureRoundSeeded(session)
p.mu.Lock()
defer p.mu.Unlock()
round := p.beginRoundLocked(session, owner)
g := trace.NewRunGraph(session)
g.SetTurnID(turnID)
g.SetSeq(p.seq[session])
g.Start(round, time.Now())
g.SeedInput(inputText)
p.graphs[owner] = g
p.roundOf[owner] = round
if p.inflight[session] == nil {
p.inflight[session] = make(map[string]bool, 2)
}
p.inflight[session][owner] = true
p.publishLocked(g)
}
// beginRoundLocked 分配(或沿用)本轮次号并清掉更早轮次的桶。调用方必须持有 p.mu。
// 计数器由 `ensureRoundSeeded` 在发号之前播种(跨重启单调,见 flow_roundseed.go)。
func (p *FlowPublisher) beginRoundLocked(session, owner string) int {
p.seq[session]++
if len(p.inflight[session]) == 0 {
p.rounds[session] = p.seq[session]
p.dropEarlierRoundsLocked(session)
}
return p.rounds[session]
}
// dropEarlierRoundsLocked 丢掉该会话**更早轮次**留下的桶与图。图的历史由落盘承载
// (`flow_graphs`),实时桶只需覆盖当前这一轮 —— 否则每一条并行路(turnId 每回合
// 全新、bucket 键因此永不复用)都会在内存里永久留一张图。调用方必须持有 p.mu。
func (p *FlowPublisher) dropEarlierRoundsLocked(session string) {
current := p.rounds[session]
for owner, round := range p.roundOf {
if round >= current {
continue
}
if s, _ := splitFlowOwner(owner); s != session {
continue
}
delete(p.roundOf, owner)
delete(p.graphs, owner)
}
}
// EndRound 用回复播种一个 output 节点并发布最终帧,
// 以此收尾该路的图。它保留该图,使错过最终帧的客户端能重放最后一份快照 ——
// **重放的出口是 `LastGraph` / `LatestLanes`**(网关只读查询,契约 v0.71/v0.77),
// 而不是又发一帧:实时帧向来只推给当时在线的连接,刷新/晚开的页面只能靠只读拉取
// 补齐(`graph_update` 本身不重放)。
//
// 收尾也是**落盘**的唯一时机(切片 F2;design/19 §4 的 P3「多轮历史回放」):每轮
// 落**一条**(绝不是每 span 一条),真并行下**每一路各一条**;且只在该会话处于发布
// 会话白名单内时发生(本方法对白名单外的会话直接返回 ⇒ 落盘出口天然只被白名单会话
// 触发)。深拷贝与写库在**放锁之后**做:一次库写绝不把其他会话的 span 发布挡住;
// 失败只记 WARN,绝不影响本轮收尾。
func (p *FlowPublisher) EndRound(owner, outputText string) {
session, _ := splitFlowOwner(owner)
if session == "" || owner == "" || !p.allowed(session) {
return
}
p.mu.Lock()
g := p.graphs[owner]
if g == nil {
p.mu.Unlock()
return
}
g.SeedOutput(outputText)
p.publishLocked(g)
var snap *trace.FlowGraph
if p.record != nil {
snap = g.Snapshot().Clone()
}
// 本路退出在飞集合(**只删自己的桶**):集合空了才说明整轮收尾,下一轮才会
// 拿到新的轮次号 —— 因此轮次号在同轮并行的两路之间保持同一个值。
delete(p.inflight[session], owner)
p.mu.Unlock()
if snap != nil {
if err := p.record.RecordRound(snap); err != nil {
p.logPersistError(session, snap.ID, err)
}
}
}
// MarkInputSteered 给本路的 input 节点打上 `steered:true` 标记并发布一帧
// (design/15 §5 / 裁决 13.4.5「注入时给 input 节点贴 steered:true 标记」)。
// 它让链路图如实表达「这一路的输入在回合中途被追加过」——图仍然只有一条主干
// (I1:同会话恒只有一个在飞回合),而不是被画成第二轮。
//
// nil 接收者、空归属、发布白名单外的会话、或该路还没有图时都是空操作;标记
// 只并入既有 input 节点(Upsert 的合并语义),绝不新建节点、绝不改动它的标签。
func (p *FlowPublisher) MarkInputSteered(owner string) {
session, _ := splitFlowOwner(owner)
if p == nil || owner == "" || session == "" || !p.allowed(session) {
return
}
p.mu.Lock()
defer p.mu.Unlock()
g := p.graphs[owner]
if g == nil {
return
}
g.Upsert(trace.NodeSpec{
ID: "input",
Kind: "input",
Props: map[string]any{"steered": true},
})
p.publishLocked(g)
}
// SpanStarted 为一个刚开始的 span 记录一个运行中的占位
// 节点。state_transition span 会被图构建器省略。
func (p *FlowPublisher) SpanStarted(owner string, sp *trace.Span) {
if sp == nil {
return
}
p.mu.Lock()
defer p.mu.Unlock()
g := p.bucketLocked(owner)
if g == nil {
return
}
if g.AddSpanStart(sp.ID, sp.Name, sp.Kind, sp.ParentID, sp.StartedAt) {
p.publishLocked(g)
}
}
// SpanFinished 把一个刚结束的 span 作为最终节点(状态 done/error)
// 并入该路的图并发布。必须在 span 的 Finish 之后调用。
func (p *FlowPublisher) SpanFinished(owner string, sp *trace.Span) {
if sp == nil {
return
}
p.mu.Lock()
defer p.mu.Unlock()
g := p.bucketLocked(owner)
if g == nil {
return
}
g.AddSpan(sp.Snapshot())
p.publishLocked(g)
}
// bucketLocked 取归属桶当前那张图;归属非法或该路还没有图时返回 nil。调用方必须
// 持有 p.mu。白名单判据与建图处同一处(bucket 拆出的会话),因此**不可能**让
// 白名单外的会话经 span 折叠进图。
func (p *FlowPublisher) bucketLocked(owner string) *trace.RunGraph {
session, _ := splitFlowOwner(owner)
if owner == "" || session == "" || !p.allowed(session) {
return nil
}
return p.graphs[owner]
}
// LastGraph 返回 sessionID **最近一路**链路图的一份只读深拷贝快照,供
// 管理面板在「轮次已经结束、实时帧已经错过」时补拉(契约 v0.71 的
// `GET /api/v1/admin/flow/graph`;design/19 §4 的 P3「多轮历史回放」的
// 最小一步)。
//
// 「最近」= 该会话所有在内存的路里**开始最晚**的那一路(同刻用图 id 定序)。
// 真并行时它只是最近的一路,**全貌**是 `LatestLanes`(契约 v0.77 的 `lanes[]`)。
//
// 它是**只读**的:不发布、不改图、不动轮次计数 —— 因此补拉绝不会在
// 管理面板里凭空造出一帧 `graph_update`,也绝不改任何会话的实时图。
//
// 该会话还没有图时返回 nil:从未跑过带 span 的轮次、或该会话本就**不在
// 发布会话范围**(`StartRound` 对白名单外的会话直接返回 ⇒ 从不建图,
// 隔离渠道会话因此天然查不到)。调用方**必须**把 nil 读作「确实没有图」,
// 绝不允许据此补画任何节点或假图。
//
// 返回的是深拷贝(`trace.FlowGraph.Clone`):读者在锁外编码时,
// 活图可能正被下一帧 span 就地合并,共享底层 map/切片会撕裂。
func (p *FlowPublisher) LastGraph(sessionID string) *trace.FlowGraph {
if p == nil || sessionID == "" {
return nil
}
p.mu.Lock()
defer p.mu.Unlock()
latest := p.latestLocked(sessionID)
if latest == nil {
return nil
}
return latest.Clone()
}
// LatestLanes 返回 sessionID **最近一轮每一路**的图快照(每条路一份深拷贝),
// 按路的开始时间升序(最早的在前)。契约 v0.77 的 `lanes[]` 就是它:真并行时
// 同一会话同一轮有多条路,`LastGraph` 只给出其中最新的一条,管理面板要靠本方法
// 才看得全(design/19 §2「并行分支独立显示」)。
//
// 没有图时返回空切片(不是 nil):调用方据此渲染诚实空态,绝不补画。
// 与 LastGraph 一样是**只读**的,且返回深拷贝(读者在锁外编码)。
func (p *FlowPublisher) LatestLanes(sessionID string) []*trace.FlowGraph {
if p == nil || sessionID == "" {
return nil
}
p.mu.Lock()
defer p.mu.Unlock()
round := p.rounds[sessionID]
owners := make([]string, 0, 2)
for owner, r := range p.roundOf {
if r != round {
continue
}
if s, _ := splitFlowOwner(owner); s == sessionID {
owners = append(owners, owner)
}
}
out := make([]*trace.FlowGraph, 0, len(owners))
for _, owner := range owners {
g := p.graphs[owner]
if g == nil {
continue
}
out = append(out, g.Snapshot().Clone())
}
sortLanes(out)
return out
}
// latestLocked 取该会话开始最晚的那一路的活图(没有时 nil)。调用方必须持有 p.mu。
func (p *FlowPublisher) latestLocked(sessionID string) *trace.FlowGraph {
var best *trace.FlowGraph
for owner, g := range p.graphs {
if s, _ := splitFlowOwner(owner); s != sessionID {
continue
}
snap := g.Snapshot()
if best == nil || laneNewer(snap, best) {
best = snap
}
}
return best
}
// laneNewer 报告 a 是否比 b 更「新」:先比开始时刻,同刻用图 id 定序(图 id 里含
// 会话级单调序号,因此定序不依赖时间分辨率)。
func laneNewer(a, b *trace.FlowGraph) bool {
if a.StartedAt.Equal(b.StartedAt) {
return a.ID > b.ID
}
return a.StartedAt.After(b.StartedAt)
}
// sortLanes 按「开始时刻升序、同刻按图 id」把一路路图排序,使一个轮次里的多条路
// 有一个确定的展示次序(绝不让展示次序随 map 遍历序抖动)。
func sortLanes(lanes []*trace.FlowGraph) {
sort.SliceStable(lanes, func(i, j int) bool {
if lanes[i].StartedAt.Equal(lanes[j].StartedAt) {
return lanes[i].ID < lanes[j].ID
}
return lanes[i].StartedAt.Before(lanes[j].StartedAt)
})
}
// publishLocked 发布 g 的一份快照。调用方必须持有 p.mu。
func (p *FlowPublisher) publishLocked(g *trace.RunGraph) {
if p.publish == nil {
return
}
p.publish(graphUpdateSubject, g.Snapshot())
}
// allowed 报告某会话的图是否应当发布。
func (p *FlowPublisher) allowed(sessionID string) bool {
if p.allow == nil {
return true
}
return p.allow(sessionID)
}
// logPersistError 把一次落盘失败记成带来源的 WARN。它只记 session/turn 标识,
// **绝不打印图内容**(图里可能含用户输入文本);logger 未接线时静默。
func (p *FlowPublisher) logPersistError(sessionID, turnID string, err error) {
if p.log == nil {
return
}
logger.WithSource(p.log, flowLogSource).Warn("flow round persist failed",
"session", sessionID, "turn", turnID, "err", err)
}
// lastUserText 返回 messages 中最新用户消息的文本,用于
// 标注该轮的 input 节点。没有用户文本时返回 ""。
func lastUserText(messages []model.Message) string {
for i := len(messages) - 1; i >= 0; i-- {
if messages[i].Role != model.RoleUser {
continue
}
if t := messageText(messages[i]); t != "" {
return t
}
}
return ""
}