Files
sundynix-agentix/sundynix-gateway/internal/store/schedule.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

107 lines
3.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package store
import (
"context"
"time"
"gorm.io/gorm"
)
// 定时任务(JARVIS 调度能力):让用户能说「每天早上九点帮我看看昨天的任务情况」。
// 存的是**自然语言指令**而非编排图——到点由 JARVIS 自己按这句话去调工具办事,
// 与语音链路同一条执行路径(见 handler/schedule_tick.go)。
//
// 触发语义刻意做成"绝对时刻推进":NextRunAt 到点即跑,跑完按 IntervalSec 顺推。
// 进程停机期间错过的**不补跑**(补跑一堆历史提醒是骚扰,不是可靠性)——直接顺推到下一个未来时刻。
type Schedule struct {
ID string `gorm:"primaryKey;size:32"`
Owner string `gorm:"size:32;index"` // 雪花 user.id
TenantID string `gorm:"size:32;index"` // 计费租户(到点提交任务时按它计费)
SessionID string `gorm:"size:64"` // 创建时的会话(供上下文续聊)
Title string `gorm:"size:200"` // 人读的名字,如「每日任务巡检」
Prompt string `gorm:"type:text"` // 到点要 JARVIS 做的事(自然语言)
IntervalSec int64 `gorm:"not null"` // 周期秒数;0 = 只跑一次
Enabled bool `gorm:"default:true;index"`
NextRunAt time.Time `gorm:"index"`
LastRunAt *time.Time
RunCount int64
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
}
func (Schedule) TableName() string { return "sundynix_schedule" }
// CreateSchedule 建一条定时任务。
func (p *Postgres) CreateSchedule(ctx context.Context, s *Schedule) error {
if p.db == nil {
return nil // 降级模式(无 PG):与其它 store 写方法同约定,静默不持久化
}
if s.ID == "" {
s.ID = NewID()
}
return p.db.WithContext(ctx).Create(s).Error
}
// ListSchedules 列某用户的定时任务(含已停用,供展示/管理)。
func (p *Postgres) ListSchedules(ctx context.Context, owner string) []Schedule {
if p.db == nil {
return nil
}
var out []Schedule
p.db.WithContext(ctx).Where("owner = ?", owner).Order("next_run_at asc").Limit(50).Find(&out)
return out
}
// CancelSchedule 停用某条定时任务(软停用而非删除,保留痕迹)。归属校验在调用方。
func (p *Postgres) CancelSchedule(ctx context.Context, owner, id string) error {
if p.db == nil {
return nil
}
return p.db.WithContext(ctx).Model(&Schedule{}).
Where("id = ? AND owner = ?", id, owner).
Update("enabled", false).Error
}
// DueSchedules 取所有到期待跑的任务(系统级扫描,跨租户——必须 WithoutTenant
// 否则租户插件会把定时器自己的扫描限死在空租户上,一条都扫不到)。
func (p *Postgres) DueSchedules(ctx context.Context, limit int) []Schedule {
if p.db == nil {
return nil
}
var out []Schedule
p.db.WithContext(WithoutTenant(ctx)).
Where("enabled = true AND next_run_at <= ?", time.Now()).
Order("next_run_at asc").Limit(limit).Find(&out)
return out
}
// AdvanceSchedule 一条任务跑完后推进到下一次:周期任务顺推到**下一个未来时刻**
// (跳过停机期间错过的,不补跑);一次性任务直接停用。
func (p *Postgres) AdvanceSchedule(ctx context.Context, s *Schedule) error {
if p.db == nil {
return nil
}
now := time.Now()
updates := map[string]any{
"last_run_at": now,
"run_count": gorm.Expr("run_count + 1"),
}
if s.IntervalSec <= 0 {
updates["enabled"] = false // 一次性:跑完即停
} else {
next := s.NextRunAt
step := time.Duration(s.IntervalSec) * time.Second
for !next.After(now) {
next = next.Add(step) // 跳过错过的周期,直接到下一个未来时刻
}
updates["next_run_at"] = next
}
return p.db.WithContext(WithoutTenant(ctx)).Model(&Schedule{}).
Where("id = ?", s.ID).Updates(updates).Error
}