package handler import ( "context" "log" "time" "github.com/sundynix/sundynix-gateway/internal/dsl" "github.com/sundynix/sundynix-gateway/internal/store" "github.com/sundynix/sundynix-shared/contract" ) // 定时任务触发器(JARVIS 调度能力):到点把用户存的那句自然语言指令交给 JARVIS 去办, // 走的正是语音/HTTP 同一条关卡(preflightCore + launchCore),跑完经语音事件主动播报。 // // 与订阅推进(subscription_tick.go)同一范式:safeGo + leader 锁 + 单轮 panic 兜底。 // 幂等靠"先推进 NextRunAt 再提交"——同一条到期任务不会被两轮重复捞起。 const ( scheduleTickInterval = 30 * time.Second scheduleLeaderKey int64 = 20260725 scheduleBatchLimit = 20 // 单轮最多跑几条,防积压时打爆 LLM ) // StartScheduleTicker 随进程生命周期运行;多副本下只有抢到 advisory 锁的实例真正扫。 func (h *Handler) StartScheduleTicker(ctx context.Context) { safeGo("schedule-ticker", func() { t := time.NewTicker(scheduleTickInterval) defer t.Stop() runTick := func() { safeCall("schedule-tick", func() { h.db.TryRunExclusive(ctx, scheduleLeaderKey, func() { h.tickSchedules(ctx) }) }) } runTick() for { select { case <-ctx.Done(): return case <-t.C: runTick() } } }) } func (h *Handler) tickSchedules(ctx context.Context) { due := h.db.DueSchedules(ctx, scheduleBatchLimit) for i := range due { s := due[i] // 先推进再提交:即使下面提交失败,也不会在下一轮被重复捞起反复烧钱。 if err := h.db.AdvanceSchedule(ctx, &s); err != nil { log.Printf("[schedule] 推进失败 id=%s: %v", s.ID, err) continue } if taskID, err := h.runSchedule(ctx, &s); err != nil { log.Printf("[schedule] 执行失败 id=%s owner=%s: %v", s.ID, s.Owner, err) // 提交失败(余额不足/租户暂停等)也告诉用户一声,别让定时任务静默消失。 h.announceToUser(s.Owner, "定时任务「"+s.Title+"」这次没能执行:"+err.Error()) } else { log.Printf("[schedule] 已触发 id=%s title=%s task=%s", s.ID, s.Title, taskID) } } } // runSchedule 把一条定时任务的自然语言指令提交成一次 JARVIS 执行。 func (h *Handler) runSchedule(ctx context.Context, s *store.Schedule) (string, error) { // 复用语音图:input(指令) → autonomous agent(带平台/本地全套工具)。 // 定时任务本质就是"替用户说了这句话",执行路径与语音完全一致,不另起炉灶。 raw := buildVoiceGraph(s.Prompt, defaultJarvisName, "这是一条定时任务,请直接把结果讲清楚,简短。") task, err := dsl.ParseAndAssemble(raw) if err != nil { return "", err } billingTenant, block := h.preflightCore(ctx, s.Owner, s.TenantID) if block != nil { return "", errScheduleBlocked{block.message()} } task.Meta[contract.MetaUserID] = s.Owner task.Meta[contract.MetaTenantID] = billingTenant task.Meta[contract.MetaSessionID] = s.SessionID task.Meta[contract.MetaModelProfile] = contract.ModelProfileVoice if err := h.launchCore(ctx, s.Owner, task); err != nil { return "", err } // 结果播报:订阅这次任务的 token 流,攒完整答复后经语音事件推给用户(没开语音则静默丢弃)。 go h.announceScheduleResult(task.ID, s.Title) return task.ID, nil } type errScheduleBlocked struct{ msg string } func (e errScheduleBlocked) Error() string { return e.msg } // announceScheduleResult 等这次定时任务的回答产出完,把结果主动播报给用户。 func (h *Handler) announceScheduleResult(taskID, title string) { done := make(chan string, 1) var buf []byte unsub, err := h.bus.SubscribeTokens(taskID, func(tok []byte) { buf = append(buf, tok...) }, func() { done <- string(buf) }, ) if err != nil { return } defer func() { _ = unsub() }() select { case answer := <-done: if answer == "" { return } if r := []rune(answer); len(r) > 300 { // 播报别太长,语音场景听不完 answer = string(r[:300]) + "…" } h.announceToUser(ownerOfTask(h, taskID), "定时任务「"+title+"」的结果:" + answer) case <-time.After(5 * time.Minute): } } // ownerOfTask 查任务归属(播报要发给正确的人)。 func ownerOfTask(h *Handler, taskID string) string { return h.db.TaskOwner(context.Background(), taskID) } // announceToUser 经语音事件通道给用户播报一句(无在线语音会话则静默丢弃)。 func (h *Handler) announceToUser(uid, text string) { if uid == "" || text == "" { return } if err := h.bus.PublishVoiceEvent(uid, &contract.VoiceEvent{Action: "announce", Text: text}); err != nil { log.Printf("[schedule] 播报失败 uid=%s: %v", uid, err) } }