348f1e0249
此前 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>
135 lines
4.7 KiB
Go
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)
|
|
}
|
|
}
|