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
@@ -71,9 +71,10 @@ func (h *Handler) SubmitTask(c *gin.Context) {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
// 从提交即开始把 token 流录进 Redis Stream(订阅早于 dispatcher 产 token)→
// SSE 可从中回放/断点续传,根治"连晚/重连丢 token"。
// 从提交即开始把 token 流 + 执行轨迹录进 Redis Stream(订阅早于 dispatcher 产)→
// SSE 可从中回放/断点续传,根治"连晚/重连丢 token / 丢轨迹事件"。
h.startTokenRecorder(task.ID)
h.startExecRecorder(task.ID)
c.JSON(http.StatusAccepted, gin.H{"task_id": task.ID})
}
@@ -85,8 +86,27 @@ func (h *Handler) startTokenRecorder(taskID string) {
}
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute) // 兜底防泄漏
unsub, err := h.bus.SubscribeTokens(taskID,
func(tok []byte) { _ = h.cache.StreamAppend(ctx, taskID, "token", string(tok)) },
func() { _ = h.cache.StreamAppend(ctx, taskID, "done", ""); cancel() },
func(tok []byte) { _ = h.cache.StreamAppend(ctx, store.ChannelToken, taskID, "token", string(tok)) },
func() { _ = h.cache.StreamAppend(ctx, store.ChannelToken, taskID, "done", ""); cancel() },
)
if err != nil {
cancel()
return
}
go func() { <-ctx.Done(); _ = unsub() }()
}
// startExecRecorder 后台订阅执行轨迹事件并落 Redis Stream(与 SSE 是否在线无关),
// 使"运行·观测"轨迹可回放/断点续传——连晚或刷新重连不再丢节点点亮/工具调用事件。
// Redis 降级时为空操作(StreamExec 自动回退到 live NATS)。
func (h *Handler) startExecRecorder(taskID string) {
if !h.cache.Enabled() {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Minute) // 含 HITL 审批等待,给足
unsub, err := h.bus.SubscribeExec(taskID,
func(data []byte) { _ = h.cache.StreamAppend(ctx, store.ChannelExec, taskID, "exec", string(data)) },
func() { _ = h.cache.StreamAppend(ctx, store.ChannelExec, taskID, "done", ""); cancel() },
)
if err != nil {
cancel()
@@ -170,7 +190,7 @@ func (h *Handler) StreamTask(c *gin.Context) {
}
ctx := c.Request.Context()
c.Stream(func(w io.Writer) bool {
entries, nl, err := h.cache.StreamRead(ctx, taskID, lastID, 20*time.Second)
entries, nl, err := h.cache.StreamRead(ctx, store.ChannelToken, taskID, lastID, 20*time.Second)
if err != nil {
return false // ctx 取消(客户端断开)或后端故障
}
@@ -258,14 +278,45 @@ func (h *Handler) Health(c *gin.Context) {
c.JSON(http.StatusOK, status)
}
// StreamExec: 订阅 sundynix.exec.<task_id>以 SSE 把执行轨迹事件推给客户端(运行·观测)。
// StreamExec: 以 SSE 把执行轨迹事件推给客户端(运行·观测)。
// 与 StreamTasktoken 流)并行:前端同时连两路,token 走输出、exec 走轨迹/工具面板。
// 优先从 Redis Stream 读(可回放 + 断点续传,根治连晚/刷新重连丢轨迹事件);Redis 降级时回退 live NATS。
func (h *Handler) StreamExec(c *gin.Context) {
taskID := c.Param("id")
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
if !h.cache.Enabled() {
h.streamExecLive(c, taskID) // Redis 不可用 → live-NATS 兜底
return
}
// 断点续传:EventSource 重连带 Last-Event-ID;缺省 "0" 从头回放(连晚也能补齐已发生的轨迹)。
lastID := c.GetHeader("Last-Event-ID")
if lastID == "" {
lastID = "0"
}
ctx := c.Request.Context()
c.Stream(func(w io.Writer) bool {
entries, nl, err := h.cache.StreamRead(ctx, store.ChannelExec, taskID, lastID, 20*time.Second)
if err != nil {
return false
}
lastID = nl
for _, e := range entries {
if e.Kind == "done" {
_ = sse.Encode(w, sse.Event{Id: e.ID, Event: "done", Data: taskID})
return false
}
_ = sse.Encode(w, sse.Event{Id: e.ID, Event: "exec", Data: e.Data})
}
return true
})
}
// streamExecLive 是 Redis 降级时的兜底:直接订阅 NATS 轨迹流转 SSE(无回放/续传能力)。
func (h *Handler) streamExecLive(c *gin.Context, taskID string) {
events := make(chan []byte, 256)
done := make(chan struct{})
unsub, err := h.bus.SubscribeExec(taskID,