feat(obs): exec 执行轨迹 Redis 回放 —— 连晚/重连不再丢轨迹事件

执行轨迹原本只走瞬时 core NATS(sundynix.exec.<id>),SSE 连晚或刷新重连就丢掉
已发生的节点点亮/工具调用/推理过程事件。本提交把它做成与 token 流同构的可回放流:

- store: Redis Stream 函数加 channel 维度(ChannelToken="stream" / ChannelExec="exec"),
  同一套 XADD/XREAD/TTL 复用;key 按 channel 分命名空间互不串扰。
- gateway: 提交即启 startExecRecorder 后台订阅轨迹落 Redis(与 SSE 是否在线无关,
  12min 兜底含 HITL 审批等待);StreamExec 改为优先 Redis 回放 + Last-Event-ID 断点续传,
  Redis 降级回退 live NATS(streamExecLive)。

单测 streamKey channel 隔离;live:任务 done 后再连 /exec,仍从 Redis 完整回放
全程轨迹(含推理过程),Redis XLEN 对账一致。

至此「可靠性细节」两项(优雅停机 + 轨迹回放)补齐。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-26 14:03:31 +08:00
parent 19df6f3a94
commit f926f6fd41
4 changed files with 94 additions and 18 deletions
+17 -10
View File
@@ -83,39 +83,46 @@ func (r *Redis) GetUsage(ctx context.Context, userID, day string) int64 {
return n
}
// ---- Token 流持久化(Redis Stream:可回放的追加日志,根治 SSE 连晚/重连丢 token----
// ---- 流持久化(Redis Stream:可回放的追加日志,根治 SSE 连晚/重连丢事件----
// 按 channel 分流:token 走 "stream",执行轨迹走 "exec"——同一套 XADD/XREAD/TTL 逻辑复用。
const streamTTL = 10 * time.Minute
func streamKey(taskID string) string { return "sundynix:stream:" + taskID }
// Channel 是回放流的种类(决定 Redis key 命名空间,互不串扰)。
const (
ChannelToken = "stream" // Token 流(与历史 key 兼容)
ChannelExec = "exec" // 执行轨迹流
)
// StreamEntry 是 token 流里的一条记录(ID 用于 SSE 的 Last-Event-ID 断点续传)。
func streamKey(channel, taskID string) string { return "sundynix:" + channel + ":" + taskID }
// StreamEntry 是回放流里的一条记录(ID 用于 SSE 的 Last-Event-ID 断点续传)。
type StreamEntry struct {
ID string
Kind string // token / done
Kind string // token / exec / done
Data string
}
// StreamAppend 把一条 token / 结束标记追加到任务的 Redis Stream(带 TTL 自动清理)。
func (r *Redis) StreamAppend(ctx context.Context, taskID, kind, data string) error {
// StreamAppend 把一条记录追加到任务某 channel 的 Redis Stream(带 TTL 自动清理)。
func (r *Redis) StreamAppend(ctx context.Context, channel, taskID, kind, data string) error {
if r.rdb == nil {
return nil
}
k := streamKey(taskID)
k := streamKey(channel, taskID)
if err := r.rdb.XAdd(ctx, &redis.XAddArgs{Stream: k, Values: map[string]any{"kind": kind, "data": data}}).Err(); err != nil {
return err
}
return r.rdb.Expire(ctx, k, streamTTL).Err()
}
// StreamRead 从 lastID 之后阻塞读取新条目(XREAD BLOCK)。lastID="0" 表示从头回放。
// StreamRead 从 lastID 之后阻塞读取某 channel 的新条目(XREAD BLOCK)。lastID="0" 表示从头回放。
// 阻塞超时无新数据时返回空切片 + 原 lastID(调用方据此继续轮询)。
func (r *Redis) StreamRead(ctx context.Context, taskID, lastID string, block time.Duration) ([]StreamEntry, string, error) {
func (r *Redis) StreamRead(ctx context.Context, channel, taskID, lastID string, block time.Duration) ([]StreamEntry, string, error) {
if r.rdb == nil {
return nil, lastID, nil
}
res, err := r.rdb.XRead(ctx, &redis.XReadArgs{
Streams: []string{streamKey(taskID), lastID}, Block: block, Count: 256,
Streams: []string{streamKey(channel, taskID), lastID}, Block: block, Count: 256,
}).Result()
if err == redis.Nil {
return nil, lastID, nil // 阻塞超时、无新条目
@@ -0,0 +1,18 @@
package store
import "testing"
// streamKey 必须按 channel 分命名空间,token 与 exec 流互不串扰。
func TestStreamKey_ChannelIsolation(t *testing.T) {
tok := streamKey(ChannelToken, "task_1")
ex := streamKey(ChannelExec, "task_1")
if tok == ex {
t.Fatalf("同任务的 token/exec 流 key 不应相同: %q", tok)
}
if tok != "sundynix:stream:task_1" {
t.Errorf("token key=%q", tok)
}
if ex != "sundynix:exec:task_1" {
t.Errorf("exec key=%q", ex)
}
}