Files
sundynix-agentix/sundynix-gateway/internal/handler/schedule_tick.go
T
Blizzard 348f1e0249 feat(jarvis): 能动的手(写文件/执行命令,三道闸) + 定时任务调度
此前 JARVIS 只能看不能动(本地工具纯只读)、也不会调度,补齐这两块。

【能动的手】local_write_file / local_exec,在用户自选工作目录内动手:
- 独立开关:只开只读访问不给这能力,须单独勾「允许写文件/执行命令」
- 原生确认框逐次审批:展示命令原文,默认按钮=拒绝,60s 无人应答按拒绝
  (防无人值守被静默批准);可选「本次会话都允许」,关开关即失效
- 硬黑名单:删库/提权/管道下载执行/写系统路径/装开机项/摸凭据等,
  用户点同意也不执行,连审批框都不弹。20 条危险命令 + 10 条正常命令单测
- 命令 cwd 锁沙箱根、60s 超时、输出 16KB 截断;非零退出不算失败(编译/测试
  错误对模型是有用信息)

【定时任务】sundynix_schedule + leader 锁 ticker(30s 扫) + 三个平台工具:
- 存自然语言指令而非编排图,到点走语音同一条关卡(preflightCore/launchCore)
  执行,跑完经语音事件主动播报结果
- 先推进 NextRunAt 再提交:提交失败也不会下轮重复捞起反复烧钱
- 停机期间错过的不补跑(补一堆历史提醒是骚扰),直接顺推到下一个未来时刻

【顺带修一个必崩的 bug】dispatcher 工具超时硬编码 3 秒,而审批要等人点
(60s)+执行(60s)——local_exec 100% 超时。改成工具在 list_tools 自报
timeout_sec(不在 dispatcher 硬编码工具名),超时链外松内紧:
dispatcher 160s > 网关 150s > runner 转发 140s > 桌面端 60+60s。

live 验证:①「写个 hello.sh 打印日期然后跑一下」→ 写+执行两步,文件真落磁盘
②「建个定时任务 35 秒后跑 wc -l」→ 到点自动触发 → 自主调 local_exec → 出结果

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 13:57:49 +08:00

135 lines
4.7 KiB
Go

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)
}
}