feat(gateway): 链路图冷启动快照(v0.71)+ 面板来源说明条 —— 修「轮次结束后打开页面一片空白」
- 断点已证 = **时序层**:graph_update 只走实时帧从不重放、flowGraphs 不持久化、补拉通路此前根本不存在(EndRound 注释承诺过「刷新能重放」,代码里从来没有)
铁证:15:16:38 页面重载抹掉 15:09 那轮图,而下一轮心跳要等 ~15:24 ⇒ 打开后台思考页时桶必然为空
- 新增只读端点 GET /api/v1/admin/flow/graph?sessionId=(owner|admin;省略=主会话;graph 与 graph_update 帧的 data 逐字同形)
无图 ⇒ 200 + graph:null(不画假图);图源未接线 ⇒ 500 flow_unavailable(不伪装成「没有图」);不审计、不发布
- loop.FlowPublisher.LastGraph:只读快照(不发布/不改图/不动轮次计数),FlowGraph.Clone 深拷贝避免锁外编码撕裂
- 前端 useFlowSnapshot:只在「本会话还没有图」时补拉(避免每行一个请求);Flow/Agents/Backthink 三处接入 + 来源说明条(明确「最近一轮」与「多轮历史回放尚未实现」)
- 变异实测:LastGraph 恒 nil ⇒ 4 条测试红;白名单去掉 sess-backthink ⇒ 对应测试红(首次变异因未使用 import 编译失败 ⇒ 已作废重做)
- ⚠️ 诚实边界:每会话只回**最近一轮**(多轮回放属 F2);白名单外会话(IM 群聊/陌生人)永远没有图;**后端重启后未跑新轮次前端点如实返 null(图是内存资产,不落库)**—— 主人要的「本地数据」正是 F2 要补的
This commit is contained in:
1 parent
a3482ed06e
commit
1bf85a955b
23 files changed
+1427
-36
No files matched your search
@@ -68,8 +68,10 @@ func (p *FlowPublisher) StartRound(sessionID, inputText string) {
|
||||
}
|
||||
|
||||
// EndRound 用回复播种一个 output 节点并发布最终帧,
|
||||
// 以此收尾该轮次。它保留该图,使错过最终帧的客户端
|
||||
// 刷新时能重放最后一份快照。
|
||||
// 以此收尾该轮次。它保留该图,使错过最终帧的客户端能重放最后一份快照 ——
|
||||
// **重放的出口是 `LastGraph`**(网关只读查询,契约 v0.71),而不是又发一帧:
|
||||
// 实时帧向来只推给当时在线的连接,刷新/晚开的页面只能靠只读拉取补齐
|
||||
// (`graph_update` 本身不重放)。
|
||||
func (p *FlowPublisher) EndRound(sessionID, outputText string) {
|
||||
if sessionID == "" || !p.allowed(sessionID) {
|
||||
return
|
||||
@@ -142,6 +144,34 @@ func (p *FlowPublisher) SpanFinished(sessionID string, sp *trace.Span) {
|
||||
p.publishLocked(g)
|
||||
}
|
||||
|
||||
// LastGraph 返回 sessionID **最近一轮**链路图的一份只读深拷贝快照,供
|
||||
// 管理面板在「轮次已经结束、实时帧已经错过」时补拉(契约 v0.71 的
|
||||
// `GET /api/v1/admin/flow/graph`;design/19 §4 的 P3「多轮历史回放」的
|
||||
// 最小一步)。
|
||||
//
|
||||
// 它是**只读**的:不发布、不改图、不动轮次计数 —— 因此补拉绝不会在
|
||||
// 管理面板里凭空造出一帧 `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()
|
||||
g := p.graphs[sessionID]
|
||||
if g == nil {
|
||||
return nil
|
||||
}
|
||||
return g.Snapshot().Clone()
|
||||
}
|
||||
|
||||
// publishLocked 发布 g 的一份快照。调用方必须持有 p.mu。
|
||||
func (p *FlowPublisher) publishLocked(g *trace.RunGraph) {
|
||||
if p.publish == nil {
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// 本文件钉住契约 v0.71 的只读快照出口 `FlowPublisher.LastGraph`:
|
||||
// 「轮次结束后才打开的页面也能看到该轮的图」。它是 loop 侧的半边;
|
||||
// 网关侧的半边(`GET /api/v1/admin/flow/graph`)在
|
||||
// internal/services/gateway/admin_flow_graph_test.go。
|
||||
|
||||
// countingPublisher 接上捕获式 publish 并计数,用来断言
|
||||
// 「只读查询不发布」。
|
||||
func countingPublisher(allow func(string) bool) (*FlowPublisher, func() int) {
|
||||
var mu sync.Mutex
|
||||
frames := 0
|
||||
p := NewFlowPublisher(func(string, any) {
|
||||
mu.Lock()
|
||||
frames++
|
||||
mu.Unlock()
|
||||
}, allow)
|
||||
return p, func() int {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return frames
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlowPublisher_LastGraph_AfterRoundEnded 跑一轮**真实**循环,然后在
|
||||
// Run 返回之后(= 所有实时帧都已发完、晚开的页面一帧都收不到)取快照:
|
||||
// 它必须仍然带上该轮的主干与回复 —— 这就是主人报的那个场景。
|
||||
func TestFlowPublisher_LastGraph_AfterRoundEnded(t *testing.T) {
|
||||
flow, frames := countingPublisher(func(string) bool { return true })
|
||||
call := `{"tool_calls":[{"name":"echo","args":{"q":"hi"}}]}`
|
||||
ad := &mockAdapter{chat: []mgResp{
|
||||
{resp: model.Response{Content: call}},
|
||||
{resp: model.Response{Content: "Echoed!"}},
|
||||
}}
|
||||
l := New(Options{
|
||||
Agent: newTestAgent(t, ad, dispatcherWith(t, &mockTool{name: "echo", result: "hi"})),
|
||||
MaxRounds: 8,
|
||||
Flow: flow,
|
||||
Tracer: trace.New(nil),
|
||||
})
|
||||
if _, err := l.Run(context.Background(), "web", []model.Message{model.TextMessage(model.RoleUser, "echo this")}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
before := frames()
|
||||
|
||||
g := flow.LastGraph("web")
|
||||
if g == nil {
|
||||
t.Fatal("LastGraph = nil,但该会话刚跑完一整轮(轮次结束后打开的页面将看不到图)")
|
||||
}
|
||||
kinds := map[string]bool{}
|
||||
for _, n := range g.Nodes {
|
||||
kinds[n.Kind] = true
|
||||
}
|
||||
for _, want := range []string{"input", "llm", "tool", "output"} {
|
||||
if !kinds[want] {
|
||||
t.Fatalf("快照缺少 %q 节点:%+v", want, g.Nodes)
|
||||
}
|
||||
}
|
||||
if g.SessionID != "web" {
|
||||
t.Fatalf("SessionID = %q, want web", g.SessionID)
|
||||
}
|
||||
if g.EndedAt.IsZero() {
|
||||
t.Fatal("EndedAt 为零值:快照不是收轮之后的那一份")
|
||||
}
|
||||
// 只读查询绝不发布 —— 否则补拉会在面板里凭空造出一帧 graph_update。
|
||||
if got := frames(); got != before {
|
||||
t.Fatalf("LastGraph 发布了 %d 帧新图(%d → %d)", got-before, before, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlowPublisher_LastGraph_KeepsLatestRound 钉住「最近一轮」的语义:
|
||||
// 第二轮开始后快照就是第二轮的,而不是第一轮残留。
|
||||
func TestFlowPublisher_LastGraph_KeepsLatestRound(t *testing.T) {
|
||||
flow, _ := countingPublisher(func(string) bool { return true })
|
||||
flow.StartRound("web", "第一轮")
|
||||
flow.EndRound("web", "第一轮回复")
|
||||
first := flow.LastGraph("web")
|
||||
if first == nil || first.ID != "web-1" {
|
||||
t.Fatalf("第一轮快照 = %+v, want id web-1", first)
|
||||
}
|
||||
flow.StartRound("web", "第二轮")
|
||||
flow.EndRound("web", "第二轮回复")
|
||||
second := flow.LastGraph("web")
|
||||
if second == nil || second.ID != "web-2" {
|
||||
t.Fatalf("第二轮快照 = %+v, want id web-2", second)
|
||||
}
|
||||
for _, n := range second.Nodes {
|
||||
if n.Kind == "input" && n.Label == "第一轮" {
|
||||
t.Fatalf("快照仍是第一轮的输入节点: %+v", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlowPublisher_LastGraph_EmptyCasesAreNil 钉住「确实没有图」的三种形态:
|
||||
// nil 接收者、空会话 id、从未建过图的会话,一律 nil(调用方据此渲染诚实空态,
|
||||
// 绝不允许补画节点)。
|
||||
func TestFlowPublisher_LastGraph_EmptyCasesAreNil(t *testing.T) {
|
||||
flow, _ := countingPublisher(func(string) bool { return true })
|
||||
flow.StartRound("web", "你好")
|
||||
|
||||
var nilPub *FlowPublisher
|
||||
if g := nilPub.LastGraph("web"); g != nil {
|
||||
t.Fatalf("nil 接收者应返回 nil,得到 %+v", g)
|
||||
}
|
||||
if g := flow.LastGraph(""); g != nil {
|
||||
t.Fatalf("空会话 id 应返回 nil,得到 %+v", g)
|
||||
}
|
||||
if g := flow.LastGraph("sess-never-ran"); g != nil {
|
||||
t.Fatalf("从未建图的会话应返回 nil,得到 %+v", g)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlowPublisher_LastGraph_WhitelistStillApplies 钉住只读查询**不绕过**
|
||||
// 发布会话白名单(design/19 §1 裁决 01.4.5 未被本版改动):白名单外的会话
|
||||
// 从未建图 ⇒ 查询也拿不到。
|
||||
func TestFlowPublisher_LastGraph_WhitelistStillApplies(t *testing.T) {
|
||||
flow, _ := countingPublisher(func(session string) bool { return session == "web" })
|
||||
flow.StartRound("web", "你好")
|
||||
flow.StartRound("sess-im-stranger", "群里的消息")
|
||||
|
||||
if g := flow.LastGraph("web"); g == nil {
|
||||
t.Fatal("白名单内的会话应当有图")
|
||||
}
|
||||
if g := flow.LastGraph("sess-im-stranger"); g != nil {
|
||||
t.Fatalf("白名单外的会话不该有图,得到 %+v", g)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlowPublisher_LastGraph_IsDetachedCopy 钉住快照是**深拷贝**:
|
||||
// 读者拿到之后在锁外编码时,活图可能正被下一帧 span 就地合并
|
||||
// (RunGraph.merge 往同一个 map 写、往同一个切片追加)—— 共享底层存储
|
||||
// 会撕裂,Go 里并发读写 map 直接是致命错误。因此改读者手里的那份,
|
||||
// 绝不能影响发布器内部的图。
|
||||
func TestFlowPublisher_LastGraph_IsDetachedCopy(t *testing.T) {
|
||||
flow, _ := countingPublisher(func(string) bool { return true })
|
||||
flow.StartRound("web", "你好")
|
||||
flow.MarkInputSteered("web")
|
||||
|
||||
first := flow.LastGraph("web")
|
||||
if first == nil {
|
||||
t.Fatal("LastGraph = nil")
|
||||
}
|
||||
for i := range first.Nodes {
|
||||
if first.Nodes[i].ID != "input" {
|
||||
continue
|
||||
}
|
||||
first.Nodes[i].Props["tampered"] = true
|
||||
first.Nodes[i].Props["steered"] = false
|
||||
}
|
||||
first.Nodes = append(first.Nodes, trace.FlowNode{ID: "ghost", Kind: "input"})
|
||||
|
||||
second := flow.LastGraph("web")
|
||||
if len(second.Nodes) == len(first.Nodes) {
|
||||
t.Fatal("第二次快照的节点数与读者改过的切片相同 ⇒ 返回的不是独立副本")
|
||||
}
|
||||
for _, n := range second.Nodes {
|
||||
if n.ID == "ghost" {
|
||||
t.Fatal("读者追加的节点漏进了发布器内部的图 ⇒ 不是深拷贝")
|
||||
}
|
||||
if n.ID != "input" {
|
||||
continue
|
||||
}
|
||||
if _, ok := n.Props["tampered"]; ok {
|
||||
t.Fatal("读者改写 props 影响到了内部图 ⇒ props 未深拷贝")
|
||||
}
|
||||
if steered, ok := n.Props["steered"].(bool); !ok || !steered {
|
||||
t.Fatalf("内部图原有的 steered 标记被读者改坏: %+v", n.Props)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user